Compare commits

...

12 Commits

Author SHA1 Message Date
Kit Langton b2fa76ff7f refactor(effect): unify service namespaces for file, format, vcs, skill, snapshot
Collapse the two-namespace pattern (e.g. File + FileService) into a single
namespace per module: Interface, Service, layer, and defaultLayer all live on
the domain namespace directly. Rename DiscoveryService → Discovery for
consistency. Remove no-op init() methods and unnecessary defaultLayer = layer
re-exports per EFFECT_MIGRATION_PLAN.md conventions.
2026-03-17 22:05:34 -04:00
Kit Langton c687262c59 refactor(effect): move account auth question modules 2026-03-17 21:07:40 -04:00
Kit Langton 7acf429d28 refactor(format): remove unsafe formatter merge cast 2026-03-17 20:55:12 -04:00
Kit Langton 8e4af17a4b refactor(effect): rename permission service namespace 2026-03-17 20:40:51 -04:00
Kit Langton ba26993361 refactor(effect): align permission and truncate entrypoints 2026-03-17 20:35:00 -04:00
Kit Langton 52df7b8927 refactor(permission): move service under namespace 2026-03-17 20:21:15 -04:00
Kit Langton 105606e389 refactor(truncation): centralize output and effect test helpers 2026-03-17 19:53:55 -04:00
Kit Langton 50dd241967 Remove unused Instances.invalidate static method 2026-03-17 15:30:22 -04:00
Kit Langton c3e10ab25b Merge branch 'refactor/effectify-snapshot' into refactor/effectify-truncate 2026-03-17 14:54:05 -04:00
Kit Langton 50ab371846 fix(e2e): dispose managed runtime after seeding 2026-03-17 14:53:29 -04:00
Kit Langton d2d8aaae22 refactor(truncation): effectify TruncateService, delete Scheduler
- TruncateService as global Effect service on ManagedRuntime with
  FileSystem for cleanup (readDirectory + remove)
- Hourly cleanup via Effect.forkScoped + Schedule.spaced
- Split into truncate-service.ts (Effect) + truncation.ts (facade)
  to avoid circular import with runtime.ts
- Shared TRUNCATION_DIR constant in truncation-dir.ts
- Delete Scheduler module (no remaining consumers)
- Remove Truncate.init() from bootstrap (cleanup starts automatically
  when runtime is created)
2026-03-17 10:09:34 -04:00
Kit Langton ab89f84b0c refactor(snapshot): effectify SnapshotService as scoped service
Convert Snapshot from a promise-based namespace with Instance ALS reads
to an Effect service on the Instances LayerMap.

- SnapshotService with ChildProcessSpawner for git subprocess execution
  and Effect FileSystem for file operations (replaces Process.run and
  raw fs calls)
- Nothrow git helper that always returns { code, text, stderr }, with
  spawn failure details preserved in stderr
- Hourly cleanup via Effect.forkScoped + Schedule.spaced (replaces
  Scheduler.register)
- Promise facade preserved for all existing callers
- Parallelized before/after git show in diffFull
- Add worktree to InstanceContext.Shape (needed for --work-tree flag)
- Add Instance.current getter for single ALS read
- Extract repeated git config flags into GIT_CORE/GIT_CFG/GIT_CFG_QUOTE
  constants
- Platform layers (NodeChildProcessSpawner, NodeFileSystem, NodePath)
  provided directly on the service layer
2026-03-17 10:08:13 -04:00
68 changed files with 2593 additions and 2778 deletions
+39 -33
View File
@@ -11,46 +11,52 @@ const seed = async () => {
const { Instance } = await import("../src/project/instance") const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap") const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Config } = await import("../src/config/config") const { Config } = await import("../src/config/config")
const { disposeRuntime } = await import("../src/effect/runtime")
const { Session } = await import("../src/session") const { Session } = await import("../src/session")
const { MessageID, PartID } = await import("../src/session/schema") const { MessageID, PartID } = await import("../src/session/schema")
const { Project } = await import("../src/project/project") const { Project } = await import("../src/project/project")
const { ModelID, ProviderID } = await import("../src/provider/schema") const { ModelID, ProviderID } = await import("../src/provider/schema")
const { ToolRegistry } = await import("../src/tool/registry") const { ToolRegistry } = await import("../src/tool/registry")
await Instance.provide({ try {
directory: dir, await Instance.provide({
init: InstanceBootstrap, directory: dir,
fn: async () => { init: InstanceBootstrap,
await Config.waitForDependencies() fn: async () => {
await ToolRegistry.ids() await Config.waitForDependencies()
await ToolRegistry.ids()
const session = await Session.create({ title }) const session = await Session.create({ title })
const messageID = MessageID.ascending() const messageID = MessageID.ascending()
const partID = PartID.ascending() const partID = PartID.ascending()
const message = { const message = {
id: messageID, id: messageID,
sessionID: session.id, sessionID: session.id,
role: "user" as const, role: "user" as const,
time: { created: now }, time: { created: now },
agent: "build", agent: "build",
model: { model: {
providerID: ProviderID.make(providerID), providerID: ProviderID.make(providerID),
modelID: ModelID.make(modelID), modelID: ModelID.make(modelID),
}, },
} }
const part = { const part = {
id: partID, id: partID,
sessionID: session.id, sessionID: session.id,
messageID, messageID,
type: "text" as const, type: "text" as const,
text, text,
time: { start: now }, time: { start: now },
} }
await Session.updateMessage(message) await Session.updateMessage(message)
await Session.updatePart(part) await Session.updatePart(part)
await Project.update({ projectID: Instance.project.id, name: "E2E Project" }) await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
}, },
}) })
} finally {
await Instance.disposeAll().catch(() => {})
await disposeRuntime().catch(() => {})
}
} }
await seed() await seed()
+355
View File
@@ -0,0 +1,355 @@
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import {
type AccountError as SchemaError,
AccessToken as SchemaAccessToken,
Account as SchemaAccount,
AccountID as SchemaAccountID,
DeviceCode as SchemaDeviceCode,
RefreshToken as SchemaRefreshToken,
AccountServiceError as SchemaServiceError,
Login as SchemaLogin,
Org as SchemaOrg,
OrgID as SchemaOrgID,
PollDenied as SchemaPollDenied,
PollError as SchemaPollError,
PollExpired as SchemaPollExpired,
PollPending as SchemaPollPending,
type PollResult as SchemaPollResult,
PollSlow as SchemaPollSlow,
PollSuccess as SchemaPollSuccess,
UserCode as SchemaUserCode,
} from "./schema"
export namespace AccountEffect {
export type Error = SchemaError
const AccessToken = SchemaAccessToken
type AccessToken = SchemaAccessToken
const Account = SchemaAccount
type Account = SchemaAccount
const AccountID = SchemaAccountID
type AccountID = SchemaAccountID
const DeviceCode = SchemaDeviceCode
type DeviceCode = SchemaDeviceCode
const RefreshToken = SchemaRefreshToken
type RefreshToken = SchemaRefreshToken
const Login = SchemaLogin
type Login = SchemaLogin
const Org = SchemaOrg
type Org = SchemaOrg
const OrgID = SchemaOrgID
type OrgID = SchemaOrgID
const PollDenied = SchemaPollDenied
const PollError = SchemaPollError
const PollExpired = SchemaPollExpired
const PollPending = SchemaPollPending
const PollSlow = SchemaPollSlow
const PollSuccess = SchemaPollSuccess
const UserCode = SchemaUserCode
type PollResult = SchemaPollResult
export type AccountOrgs = {
account: Account
orgs: readonly Org[]
}
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
verification_uri_complete: Schema.String,
expires_in: DurationFromSeconds,
interval: DurationFromSeconds,
}) {}
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
toPollResult(): PollResult {
if (this.error === "authorization_pending") return new PollPending()
if (this.error === "slow_down") return new PollSlow()
if (this.error === "expired_token") return new PollExpired()
if (this.error === "access_denied") return new PollDenied()
return new PollError({ cause: this.error })
}
}
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
grant_type: Schema.String,
device_code: DeviceCode,
client_id: Schema.String,
}) {}
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
}) {}
const client_id = "opencode-cli"
const map =
(message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, SchemaServiceError, R> =>
effect.pipe(
Effect.mapError((cause) =>
cause instanceof SchemaServiceError ? cause : new SchemaServiceError({ message, cause }),
),
)
export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Account>, Error>
readonly list: () => Effect.Effect<Account[], Error>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], Error>
readonly remove: (accountID: AccountID) => Effect.Effect<void, Error>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, Error>
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], Error>
readonly config: (accountID: AccountID, orgID: OrgID) => Effect.Effect<Option.Option<Record<string, unknown>>, Error>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, Error>
readonly login: (url: string) => Effect.Effect<Login, Error>
readonly poll: (input: Login) => Effect.Effect<PollResult, Error>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
const httpReadOk = HttpClient.filterStatusOk(httpRead)
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(map("HTTP request failed"))
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
httpReadOk.execute(request).pipe(map("HTTP request failed"))
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => httpOk.execute(req)),
map("HTTP request failed"),
)
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
new TokenRefreshRequest({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
client_id,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
map("Failed to decode response"),
)
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
yield* repo.persistToken({
accountID: row.id,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
})
return parsed.access_token
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybe = yield* repo.getRow(accountID)
if (Option.isNone(maybe)) return Option.none()
const account = maybe.value
const accessToken = yield* resolveToken(account)
return Option.some({ account, accessToken })
})
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
map("Failed to decode response"),
)
})
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(map("Failed to decode response"))
})
const token = Effect.fn("Account.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const orgs = Effect.fn("Account.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
return yield* fetchOrgs(resolved.value.account.url, resolved.value.accessToken)
})
const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () {
const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
for (const err of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(Effect.annotateLogs({ error: String(err) }))
}
return results
})
const config = Effect.fn("Account.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const response = yield* executeRead(
HttpClientRequest.get(`${resolved.value.account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(resolved.value.accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
if (response.status === 404) return Option.none()
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(map())
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(map("Failed to decode response"))
return Option.some(parsed.config)
})
const login = Effect.fn("Account.login")(function* (server: string) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id })),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(map("Failed to decode response"))
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`,
server,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
new DeviceTokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(map("Failed to decode response"))
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
const [account, remoteOrgs] = yield* Effect.all(
[fetchUser(input.server, parsed.access_token), fetchOrgs(input.server, parsed.access_token)],
{ concurrency: 2 },
)
const first = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
const expiry = (yield* Clock.currentTimeMillis) + Duration.toMillis(parsed.expires_in)
yield* repo.persistAccount({
id: account.id,
email: account.email,
url: input.server,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
orgID: first,
})
return new PollSuccess({ email: account.email })
})
return Service.of({
active: repo.active,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
}
+17 -11
View File
@@ -1,27 +1,33 @@
import { Effect, Option } from "effect" import { Effect, Option } from "effect"
import { AccountEffect } from "./effect"
import { import {
AccessToken as Token,
Account as AccountSchema, Account as AccountSchema,
type AccountError, type AccountError,
type AccessToken, AccountID as ID,
AccountID, OrgID as Org,
AccountService, } from "./schema"
OrgID,
} from "./service"
export { AccessToken, AccountID, OrgID } from "./service"
import { runtime } from "@/effect/runtime" import { runtime } from "@/effect/runtime"
function runSync<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) { export { AccessToken, AccountID, OrgID } from "./schema"
return runtime.runSync(AccountService.use(f))
function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountEffect.Error>) {
return runtime.runSync(AccountEffect.Service.use(f))
} }
function runPromise<A>(f: (service: AccountService.Service) => Effect.Effect<A, AccountError>) { function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
return runtime.runPromise(AccountService.use(f)) return runtime.runPromise(AccountEffect.Service.use(f))
} }
export namespace Account { export namespace Account {
export const AccessToken = Token
export type AccessToken = Token
export const AccountID = ID
export type AccountID = ID
export const OrgID = Org
export type OrgID = Org
export const Account = AccountSchema export const Account = AccountSchema
export type Account = AccountSchema export type Account = AccountSchema
-359
View File
@@ -1,359 +0,0 @@
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import {
type AccountError,
AccessToken,
Account,
AccountID,
DeviceCode,
RefreshToken,
AccountServiceError,
Login,
Org,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
UserCode,
} from "./schema"
export * from "./schema"
export type AccountOrgs = {
account: Account
orgs: readonly Org[]
}
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
const DurationFromSeconds = Schema.Number.pipe(
Schema.decodeTo(Schema.Duration, {
decode: SchemaGetter.transform((n) => Duration.seconds(n)),
encode: SchemaGetter.transform((d) => Duration.toSeconds(d)),
}),
)
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
access_token: AccessToken,
refresh_token: RefreshToken,
expires_in: DurationFromSeconds,
}) {}
class DeviceAuth extends Schema.Class<DeviceAuth>("DeviceAuth")({
device_code: DeviceCode,
user_code: UserCode,
verification_uri_complete: Schema.String,
expires_in: DurationFromSeconds,
interval: DurationFromSeconds,
}) {}
class DeviceTokenSuccess extends Schema.Class<DeviceTokenSuccess>("DeviceTokenSuccess")({
access_token: AccessToken,
refresh_token: RefreshToken,
token_type: Schema.Literal("Bearer"),
expires_in: DurationFromSeconds,
}) {}
class DeviceTokenError extends Schema.Class<DeviceTokenError>("DeviceTokenError")({
error: Schema.String,
error_description: Schema.String,
}) {
toPollResult(): PollResult {
if (this.error === "authorization_pending") return new PollPending()
if (this.error === "slow_down") return new PollSlow()
if (this.error === "expired_token") return new PollExpired()
if (this.error === "access_denied") return new PollDenied()
return new PollError({ cause: this.error })
}
}
const DeviceToken = Schema.Union([DeviceTokenSuccess, DeviceTokenError])
class User extends Schema.Class<User>("User")({
id: AccountID,
email: Schema.String,
}) {}
class ClientId extends Schema.Class<ClientId>("ClientId")({ client_id: Schema.String }) {}
class DeviceTokenRequest extends Schema.Class<DeviceTokenRequest>("DeviceTokenRequest")({
grant_type: Schema.String,
device_code: DeviceCode,
client_id: Schema.String,
}) {}
class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefreshRequest")({
grant_type: Schema.String,
refresh_token: RefreshToken,
client_id: Schema.String,
}) {}
const clientId = "opencode-cli"
const mapAccountServiceError =
(message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
effect.pipe(
Effect.mapError((cause) =>
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
),
)
export namespace AccountService {
export interface Service {
readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
readonly list: () => Effect.Effect<Account[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
readonly orgs: (accountID: AccountID) => Effect.Effect<readonly Org[], AccountError>
readonly config: (
accountID: AccountID,
orgID: OrgID,
) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
readonly login: (url: string) => Effect.Effect<Login, AccountError>
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
}
}
export class AccountService extends ServiceMap.Service<AccountService, AccountService.Service>()("@opencode/Account") {
static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
AccountService,
Effect.gen(function* () {
const repo = yield* AccountRepo
const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http)
const httpReadOk = HttpClient.filterStatusOk(httpRead)
const executeRead = (request: HttpClientRequest.HttpClientRequest) =>
httpRead.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeReadOk = (request: HttpClientRequest.HttpClientRequest) =>
httpReadOk.execute(request).pipe(mapAccountServiceError("HTTP request failed"))
const executeEffectOk = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => httpOk.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
// Returns a usable access token for a stored account row, refreshing and
// persisting it when the cached token has expired.
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(TokenRefreshRequest)(
new TokenRefreshRequest({
grant_type: "refresh_token",
refresh_token: row.refresh_token,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
const expiry = Option.some(now + Duration.toMillis(parsed.expires_in))
yield* repo.persistToken({
accountID: row.id,
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token,
expiry,
})
return parsed.access_token
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none()
const account = maybeAccount.value
const accessToken = yield* resolveToken(account)
return Option.some({ account, accessToken })
})
const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/orgs`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(Schema.Array(Org))(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) {
const response = yield* executeReadOk(
HttpClientRequest.get(`${url}/api/user`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
),
)
return yield* HttpClientResponse.schemaBodyJson(User)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
})
const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
)
const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
const accounts = yield* repo.list()
const [errors, results] = yield* Effect.partition(
accounts,
(account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
{ concurrency: 3 },
)
for (const error of errors) {
yield* Effect.logWarning("failed to fetch orgs for account").pipe(
Effect.annotateLogs({ error: String(error) }),
)
}
return results
})
const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return []
const { account, accessToken } = resolved.value
return yield* fetchOrgs(account.url, accessToken)
})
const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
const resolved = yield* resolveAccess(accountID)
if (Option.isNone(resolved)) return Option.none()
const { account, accessToken } = resolved.value
const response = yield* executeRead(
HttpClientRequest.get(`${account.url}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({ "x-org-id": orgID }),
),
)
if (response.status === 404) return Option.none()
const ok = yield* HttpClientResponse.filterStatusOk(response).pipe(mapAccountServiceError())
const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok).pipe(
mapAccountServiceError("Failed to decode response"),
)
return Option.some(parsed.config)
})
const login = Effect.fn("AccountService.login")(function* (server: string) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceAuth)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
return new Login({
code: parsed.device_code,
user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`,
server,
expiry: parsed.expires_in,
interval: parsed.interval,
})
})
const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
const response = yield* executeEffectOk(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
new DeviceTokenRequest({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: input.code,
client_id: clientId,
}),
),
),
)
const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
mapAccountServiceError("Failed to decode response"),
)
if (parsed instanceof DeviceTokenError) return parsed.toPollResult()
const accessToken = parsed.access_token
const user = fetchUser(input.server, accessToken)
const orgs = fetchOrgs(input.server, accessToken)
const [account, remoteOrgs] = yield* Effect.all([user, orgs], { concurrency: 2 })
// TODO: When there are multiple orgs, let the user choose
const firstOrgID = remoteOrgs.length > 0 ? Option.some(remoteOrgs[0].id) : Option.none<OrgID>()
const now = yield* Clock.currentTimeMillis
const expiry = now + Duration.toMillis(parsed.expires_in)
const refreshToken = parsed.refresh_token
yield* repo.persistAccount({
id: account.id,
email: account.email,
url: input.server,
accessToken,
refreshToken,
expiry,
orgID: firstOrgID,
})
return new PollSuccess({ email: account.email })
})
return AccountService.of({
active: repo.active,
list: repo.list,
orgsByAccount,
remove: repo.remove,
use: repo.use,
orgs,
config,
token,
login,
poll,
})
}),
)
static readonly defaultLayer = AccountService.layer.pipe(
Layer.provide(AccountRepo.layer),
Layer.provide(FetchHttpClient.layer),
)
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai" import { generateObject, streamObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system" import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncation" import { Truncate } from "../tool/truncate"
import { Auth } from "../auth" import { Auth } from "../auth"
import { ProviderTransform } from "../provider/transform" import { ProviderTransform } from "../provider/transform"
@@ -14,7 +14,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt" import PROMPT_TITLE from "./prompt/title.txt"
import { PermissionNext } from "@/permission/next" import { PermissionNext } from "@/permission"
import { mergeDeep, pipe, sortBy, values } from "remeda" import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global" import { Global } from "@/global"
import path from "path" import path from "path"
+98
View File
@@ -0,0 +1,98 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
export namespace AuthEffect {
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class ApiAuth extends Schema.Class<ApiAuth>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
export const Info = Schema.Union([Oauth, ApiAuth, WellKnown])
export type Info = Schema.Schema.Type<typeof Info>
export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export type Error = AuthServiceError
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause })
export interface Interface {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: fail("Failed to write auth data"),
})
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
})
return Service.of({ get, all, set, remove })
}),
)
export const defaultLayer = layer
}
+4 -4
View File
@@ -1,12 +1,12 @@
import { Effect } from "effect" import { Effect } from "effect"
import z from "zod" import z from "zod"
import { runtime } from "@/effect/runtime" import { runtime } from "@/effect/runtime"
import * as S from "./service" import * as S from "./effect"
export { OAUTH_DUMMY_KEY } from "./service" export const OAUTH_DUMMY_KEY = S.AuthEffect.OAUTH_DUMMY_KEY
function runPromise<A>(f: (service: S.AuthService.Service) => Effect.Effect<A, S.AuthServiceError>) { function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthEffect.Error>) {
return runtime.runPromise(S.AuthService.use(f)) return runtime.runPromise(S.AuthEffect.Service.use(f))
} }
export namespace Auth { export namespace Auth {
-101
View File
@@ -1,101 +0,0 @@
import path from "path"
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
export class Oauth extends Schema.Class<Oauth>("OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: Schema.Number,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
}) {}
export class Api extends Schema.Class<Api>("ApiAuth")({
type: Schema.Literal("api"),
key: Schema.String,
}) {}
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
type: Schema.Literal("wellknown"),
key: Schema.String,
token: Schema.String,
}) {}
export const Info = Schema.Union([Oauth, Api, WellKnown])
export type Info = Schema.Schema.Type<typeof Info>
export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
const file = path.join(Global.Path.data, "auth.json")
const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause })
export namespace AuthService {
export interface Service {
readonly get: (providerID: string) => Effect.Effect<Info | undefined, AuthServiceError>
readonly all: () => Effect.Effect<Record<string, Info>, AuthServiceError>
readonly set: (key: string, info: Info) => Effect.Effect<void, AuthServiceError>
readonly remove: (key: string) => Effect.Effect<void, AuthServiceError>
}
}
export class AuthService extends ServiceMap.Service<AuthService, AuthService.Service>()("@opencode/Auth") {
static readonly layer = Layer.effect(
AuthService,
Effect.gen(function* () {
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("AuthService.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
const get = Effect.fn("AuthService.get")(function* (providerID: string) {
return (yield* all())[providerID]
})
const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: fail("Failed to write auth data"),
})
})
const remove = Effect.fn("AuthService.remove")(function* (key: string) {
const norm = key.replace(/\/+$/, "")
const data = yield* all()
delete data[key]
delete data[norm]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
})
return AuthService.of({
get,
all,
set,
remove,
})
}),
)
static readonly defaultLayer = AuthService.layer
}
+6 -6
View File
@@ -2,8 +2,8 @@ import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect" import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui" import { UI } from "../ui"
import { runtime } from "@/effect/runtime" import { runtime } from "@/effect/runtime"
import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service" import { AccountEffect } from "@/account/effect"
import { type AccountError } from "@/account/schema" import { type AccountError, AccountID, OrgID, PollExpired, type PollResult } from "@/account/schema"
import * as Prompt from "../effect/prompt" import * as Prompt from "../effect/prompt"
import open from "open" import open from "open"
@@ -17,7 +17,7 @@ const isActiveOrgChoice = (
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID ) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
const loginEffect = Effect.fn("login")(function* (url: string) { const loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* AccountService const service = yield* AccountEffect.Service
yield* Prompt.intro("Log in") yield* Prompt.intro("Log in")
const login = yield* service.login(url) const login = yield* service.login(url)
@@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) {
}) })
const logoutEffect = Effect.fn("logout")(function* (email?: string) { const logoutEffect = Effect.fn("logout")(function* (email?: string) {
const service = yield* AccountService const service = yield* AccountEffect.Service
const accounts = yield* service.list() const accounts = yield* service.list()
if (accounts.length === 0) return yield* println("Not logged in") if (accounts.length === 0) return yield* println("Not logged in")
@@ -98,7 +98,7 @@ interface OrgChoice {
} }
const switchEffect = Effect.fn("switch")(function* () { const switchEffect = Effect.fn("switch")(function* () {
const service = yield* AccountService const service = yield* AccountEffect.Service
const groups = yield* service.orgsByAccount() const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("Not logged in") if (groups.length === 0) return yield* println("Not logged in")
@@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () {
}) })
const orgsEffect = Effect.fn("orgs")(function* () { const orgsEffect = Effect.fn("orgs")(function* () {
const service = yield* AccountService const service = yield* AccountEffect.Service
const groups = yield* service.orgsByAccount() const groups = yield* service.orgsByAccount()
if (groups.length === 0) return yield* println("No accounts found") if (groups.length === 0) return yield* println("No accounts found")
+1 -1
View File
@@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
import { MessageID, PartID } from "../../../session/schema" import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "../../../tool/registry" import { ToolRegistry } from "../../../tool/registry"
import { Instance } from "../../../project/instance" import { Instance } from "../../../project/instance"
import { PermissionNext } from "../../../permission/next" import { PermissionNext } from "../../../permission"
import { iife } from "../../../util/iife" import { iife } from "../../../util/iife"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"
+1 -1
View File
@@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
import { Server } from "../../server/server" import { Server } from "../../server/server"
import { Provider } from "../../provider/provider" import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
import { PermissionNext } from "../../permission/next" import { PermissionNext } from "../../permission"
import { Tool } from "../../tool/tool" import { Tool } from "../../tool/tool"
import { GlobTool } from "../../tool/glob" import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep" import { GrepTool } from "../../tool/grep"
@@ -1,13 +1,15 @@
import { ServiceMap } from "effect" import { ServiceMap } from "effect";
import type { Project } from "@/project/project" import type { Project } from "@/project/project";
export declare namespace InstanceContext { export declare namespace InstanceContext {
export interface Shape { export interface Shape {
readonly directory: string readonly directory: string;
readonly project: Project.Info readonly worktree: string;
} readonly project: Project.Info;
}
} }
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()( export class InstanceContext extends ServiceMap.Service<
"opencode/InstanceContext", InstanceContext,
) {} InstanceContext.Shape
>()("opencode/InstanceContext") {}
+43 -37
View File
@@ -1,43 +1,51 @@
import { Effect, Layer, LayerMap, ServiceMap } from "effect" import { Effect, Layer, LayerMap, ServiceMap } from "effect"
import { registerDisposer } from "./instance-registry" import { File } from "@/file"
import { InstanceContext } from "./instance-context" import { FileTime } from "@/file/time"
import { ProviderAuthService } from "@/provider/auth-service" import { FileWatcher } from "@/file/watcher"
import { QuestionService } from "@/question/service" import { Format } from "@/format"
import { PermissionService } from "@/permission/service" import { PermissionEffect } from "@/permission/effect"
import { FileWatcherService } from "@/file/watcher"
import { VcsService } from "@/project/vcs"
import { FileTimeService } from "@/file/time"
import { FormatService } from "@/format"
import { FileService } from "@/file"
import { SkillService } from "@/skill/skill"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { Vcs } from "@/project/vcs"
import { ProviderAuthEffect } from "@/provider/auth-effect"
import { QuestionEffect } from "@/question/effect"
import { Skill } from "@/skill/skill"
import { Snapshot } from "@/snapshot"
import { InstanceContext } from "./instance-context"
import { registerDisposer } from "./instance-registry"
export { InstanceContext } from "./instance-context" export { InstanceContext } from "./instance-context"
export type InstanceServices = export type InstanceServices =
| QuestionService | QuestionEffect.Service
| PermissionService | PermissionEffect.Service
| ProviderAuthService | ProviderAuthEffect.Service
| FileWatcherService | FileWatcher.Service
| VcsService | Vcs.Service
| FileTimeService | FileTime.Service
| FormatService | Format.Service
| FileService | File.Service
| SkillService | Skill.Service
| Snapshot.Service
function lookup(directory: string) { // NOTE: LayerMap only passes the key (directory string) to lookup, but we need
const project = Instance.project // the full instance context (directory, worktree, project). We read from the
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of({ directory, project })) // legacy Instance ALS here, which is safe because lookup is only triggered via
// runPromiseInstance -> Instances.get, which always runs inside Instance.provide.
// This should go away once the old Instance type is removed and lookup can load
// the full context directly.
function lookup(_key: string) {
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current))
return Layer.mergeAll( return Layer.mergeAll(
Layer.fresh(QuestionService.layer), Layer.fresh(QuestionEffect.layer),
Layer.fresh(PermissionService.layer), Layer.fresh(PermissionEffect.layer),
Layer.fresh(ProviderAuthService.layer), Layer.fresh(ProviderAuthEffect.defaultLayer),
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), Layer.fresh(FileWatcher.layer).pipe(Layer.orDie),
Layer.fresh(VcsService.layer), Layer.fresh(Vcs.layer),
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), Layer.fresh(FileTime.layer).pipe(Layer.orDie),
Layer.fresh(FormatService.layer), Layer.fresh(Format.layer),
Layer.fresh(FileService.layer), Layer.fresh(File.layer),
Layer.fresh(SkillService.layer), Layer.fresh(Skill.defaultLayer),
Layer.fresh(Snapshot.defaultLayer),
).pipe(Layer.provide(ctx)) ).pipe(Layer.provide(ctx))
} }
@@ -47,7 +55,9 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
static readonly layer = Layer.effect( static readonly layer = Layer.effect(
Instances, Instances,
Effect.gen(function* () { Effect.gen(function* () {
const layerMap = yield* LayerMap.make(lookup, { idleTimeToLive: Infinity }) const layerMap = yield* LayerMap.make(lookup, {
idleTimeToLive: Infinity,
})
const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory))) const unregister = registerDisposer((directory) => Effect.runPromise(layerMap.invalidate(directory)))
yield* Effect.addFinalizer(() => Effect.sync(unregister)) yield* Effect.addFinalizer(() => Effect.sync(unregister))
return Instances.of(layerMap) return Instances.of(layerMap)
@@ -57,8 +67,4 @@ export class Instances extends ServiceMap.Service<Instances, LayerMap.LayerMap<s
static get(directory: string): Layer.Layer<InstanceServices, never, Instances> { static get(directory: string): Layer.Layer<InstanceServices, never, Instances> {
return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory)))) return Layer.unwrap(Instances.use((map) => Effect.succeed(map.get(directory))))
} }
static invalidate(directory: string): Effect.Effect<void, never, Instances> {
return Instances.use((map) => map.invalidate(directory))
}
} }
+12 -3
View File
@@ -1,14 +1,23 @@
import { Effect, Layer, ManagedRuntime } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import { AccountService } from "@/account/service" import { AccountEffect } from "@/account/effect"
import { AuthService } from "@/auth/service" import { AuthEffect } from "@/auth/effect"
import { Instances } from "@/effect/instances" import { Instances } from "@/effect/instances"
import type { InstanceServices } from "@/effect/instances" import type { InstanceServices } from "@/effect/instances"
import { TruncateEffect } from "@/tool/truncate-effect"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
export const runtime = ManagedRuntime.make( export const runtime = ManagedRuntime.make(
Layer.mergeAll(AccountService.defaultLayer, Instances.layer).pipe(Layer.provideMerge(AuthService.defaultLayer)), Layer.mergeAll(
AccountEffect.defaultLayer, //
TruncateEffect.defaultLayer,
Instances.layer,
).pipe(Layer.provideMerge(AuthEffect.defaultLayer)),
) )
export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) { export function runPromiseInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory)))) return runtime.runPromise(effect.pipe(Effect.provide(Instances.get(Instance.directory))))
} }
export function disposeRuntime() {
return runtime.dispose()
}
+340 -370
View File
@@ -1,272 +1,20 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import fs from "fs"
import ignore from "ignore"
import { Log } from "../util/log"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort"
import { Global } from "../global"
import { git } from "@/util/git"
import { Protected } from "./protected"
import { InstanceContext } from "@/effect/instance-context" import { InstanceContext } from "@/effect/instance-context"
import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { git } from "@/util/git"
const log = Log.create({ service: "file" }) import { Effect, Layer, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff"
const binaryExtensions = new Set([ import fs from "fs"
"exe", import fuzzysort from "fuzzysort"
"dll", import ignore from "ignore"
"pdb", import path from "path"
"bin", import z from "zod"
"so", import { Global } from "../global"
"dylib", import { Instance } from "../project/instance"
"o", import { Filesystem } from "../util/filesystem"
"a", import { Log } from "../util/log"
"lib", import { Protected } from "./protected"
"wav", import { Ripgrep } from "./ripgrep"
"mp3",
"ogg",
"oga",
"ogv",
"ogx",
"flac",
"aac",
"wma",
"m4a",
"weba",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"webm",
"mkv",
"zip",
"tar",
"gz",
"gzip",
"bz",
"bz2",
"bzip",
"bzip2",
"7z",
"rar",
"xz",
"lz",
"z",
"pdf",
"doc",
"docx",
"ppt",
"pptx",
"xls",
"xlsx",
"dmg",
"iso",
"img",
"vmdk",
"ttf",
"otf",
"woff",
"woff2",
"eot",
"sqlite",
"db",
"mdb",
"apk",
"ipa",
"aab",
"xapk",
"app",
"pkg",
"deb",
"rpm",
"snap",
"flatpak",
"appimage",
"msi",
"msp",
"jar",
"war",
"ear",
"class",
"kotlin_module",
"dex",
"vdex",
"odex",
"oat",
"art",
"wasm",
"wat",
"bc",
"ll",
"s",
"ko",
"sys",
"drv",
"efi",
"rom",
"com",
"cmd",
"ps1",
"sh",
"bash",
"zsh",
"fish",
])
const imageExtensions = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp",
"ico",
"tif",
"tiff",
"svg",
"svgz",
"avif",
"apng",
"jxl",
"heic",
"heif",
"raw",
"cr2",
"nef",
"arw",
"dng",
"orf",
"raf",
"pef",
"x3f",
])
const textExtensions = new Set([
"ts",
"tsx",
"mts",
"cts",
"mtsx",
"ctsx",
"js",
"jsx",
"mjs",
"cjs",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"psm1",
"cmd",
"bat",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"toml",
"md",
"mdx",
"txt",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"graphql",
"gql",
"sql",
"ini",
"cfg",
"conf",
"env",
])
const textNames = new Set([
"dockerfile",
"makefile",
".gitignore",
".gitattributes",
".editorconfig",
".npmrc",
".nvmrc",
".prettierrc",
".eslintrc",
])
function isImageByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return imageExtensions.has(ext)
}
function isTextByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return textExtensions.has(ext)
}
function isTextByName(filepath: string): boolean {
const name = path.basename(filepath).toLowerCase()
return textNames.has(name)
}
function getImageMimeType(filepath: string): string {
const ext = path.extname(filepath).toLowerCase().slice(1)
const mimeTypes: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
bmp: "image/bmp",
webp: "image/webp",
ico: "image/x-icon",
tif: "image/tiff",
tiff: "image/tiff",
svg: "image/svg+xml",
svgz: "image/svg+xml",
avif: "image/avif",
apng: "image/apng",
jxl: "image/jxl",
heic: "image/heic",
heif: "image/heif",
}
return mimeTypes[ext] || "image/" + ext
}
function isBinaryByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return binaryExtensions.has(ext)
}
function isImage(mimeType: string): boolean {
return mimeType.startsWith("image/")
}
function shouldEncode(mimeType: string): boolean {
const type = mimeType.toLowerCase()
log.info("shouldEncode", { type })
if (!type) return false
if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false
const parts = type.split("/", 2)
const top = parts[0]
const tops = ["image", "audio", "video", "font", "model", "multipart"]
if (tops.includes(top)) return true
return false
}
export namespace File { export namespace File {
export const Info = z export const Info = z
@@ -336,28 +84,270 @@ export namespace File {
} }
export function init() { export function init() {
return runPromiseInstance(FileService.use((s) => s.init())) return runPromiseInstance(Service.use((svc) => svc.init()))
} }
export async function status() { export async function status() {
return runPromiseInstance(FileService.use((s) => s.status())) return runPromiseInstance(Service.use((svc) => svc.status()))
} }
export async function read(file: string): Promise<Content> { export async function read(file: string): Promise<Content> {
return runPromiseInstance(FileService.use((s) => s.read(file))) return runPromiseInstance(Service.use((svc) => svc.read(file)))
} }
export async function list(dir?: string) { export async function list(dir?: string) {
return runPromiseInstance(FileService.use((s) => s.list(dir))) return runPromiseInstance(Service.use((svc) => svc.list(dir)))
} }
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromiseInstance(FileService.use((s) => s.search(input))) return runPromiseInstance(Service.use((svc) => svc.search(input)))
} }
}
export namespace FileService { const log = Log.create({ service: "file" })
export interface Service {
const binary = new Set([
"exe",
"dll",
"pdb",
"bin",
"so",
"dylib",
"o",
"a",
"lib",
"wav",
"mp3",
"ogg",
"oga",
"ogv",
"ogx",
"flac",
"aac",
"wma",
"m4a",
"weba",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"webm",
"mkv",
"zip",
"tar",
"gz",
"gzip",
"bz",
"bz2",
"bzip",
"bzip2",
"7z",
"rar",
"xz",
"lz",
"z",
"pdf",
"doc",
"docx",
"ppt",
"pptx",
"xls",
"xlsx",
"dmg",
"iso",
"img",
"vmdk",
"ttf",
"otf",
"woff",
"woff2",
"eot",
"sqlite",
"db",
"mdb",
"apk",
"ipa",
"aab",
"xapk",
"app",
"pkg",
"deb",
"rpm",
"snap",
"flatpak",
"appimage",
"msi",
"msp",
"jar",
"war",
"ear",
"class",
"kotlin_module",
"dex",
"vdex",
"odex",
"oat",
"art",
"wasm",
"wat",
"bc",
"ll",
"s",
"ko",
"sys",
"drv",
"efi",
"rom",
"com",
"cmd",
"ps1",
"sh",
"bash",
"zsh",
"fish",
])
const image = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"webp",
"ico",
"tif",
"tiff",
"svg",
"svgz",
"avif",
"apng",
"jxl",
"heic",
"heif",
"raw",
"cr2",
"nef",
"arw",
"dng",
"orf",
"raf",
"pef",
"x3f",
])
const text = new Set([
"ts",
"tsx",
"mts",
"cts",
"mtsx",
"ctsx",
"js",
"jsx",
"mjs",
"cjs",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"psm1",
"cmd",
"bat",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"toml",
"md",
"mdx",
"txt",
"xml",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"graphql",
"gql",
"sql",
"ini",
"cfg",
"conf",
"env",
])
const textName = new Set([
"dockerfile",
"makefile",
".gitignore",
".gitattributes",
".editorconfig",
".npmrc",
".nvmrc",
".prettierrc",
".eslintrc",
])
const mime: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
bmp: "image/bmp",
webp: "image/webp",
ico: "image/x-icon",
tif: "image/tiff",
tiff: "image/tiff",
svg: "image/svg+xml",
svgz: "image/svg+xml",
avif: "image/avif",
apng: "image/apng",
jxl: "image/jxl",
heic: "image/heic",
heif: "image/heif",
}
type Entry = { files: string[]; dirs: string[] }
const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
const name = (file: string) => path.basename(file).toLowerCase()
const isImageByExtension = (file: string) => image.has(ext(file))
const isTextByExtension = (file: string) => text.has(ext(file))
const isTextByName = (file: string) => textName.has(name(file))
const isBinaryByExtension = (file: string) => binary.has(ext(file))
const isImage = (mimeType: string) => mimeType.startsWith("image/")
const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
function shouldEncode(mimeType: string) {
const type = mimeType.toLowerCase()
log.info("shouldEncode", { type })
if (!type) return false
if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false
const top = type.split("/", 2)[0]
return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
}
const hidden = (item: string) => {
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
}
const sortHiddenLast = (items: string[], prefer: boolean) => {
if (prefer) return items
const visible: string[] = []
const hiddenItems: string[] = []
for (const item of items) {
if (hidden(item)) hiddenItems.push(item)
else visible.push(item)
}
return [...visible, ...hiddenItems]
}
export interface Interface {
readonly init: () => Effect.Effect<void> readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<File.Info[]> readonly status: () => Effect.Effect<File.Info[]>
readonly read: (file: string) => Effect.Effect<File.Content> readonly read: (file: string) => Effect.Effect<File.Content>
@@ -369,36 +359,29 @@ export namespace FileService {
type?: "file" | "directory" type?: "file" | "directory"
}) => Effect.Effect<string[]> }) => Effect.Effect<string[]>
} }
}
export class FileService extends ServiceMap.Service<FileService, FileService.Service>()("@opencode/File") { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
static readonly layer = Layer.effect(
FileService, export const layer = Layer.effect(
Service,
Effect.gen(function* () { Effect.gen(function* () {
const instance = yield* InstanceContext const instance = yield* InstanceContext
// File cache state
type Entry = { files: string[]; dirs: string[] }
let cache: Entry = { files: [], dirs: [] } let cache: Entry = { files: [], dirs: [] }
let task: Promise<void> | undefined let task: Promise<void> | undefined
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global" const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
function kick() { function kick() {
if (task) return task if (task) return task
task = (async () => { task = (async () => {
// Disable scanning if in root of file system
if (instance.directory === path.parse(instance.directory).root) return if (instance.directory === path.parse(instance.directory).root) return
const next: Entry = { files: [], dirs: [] } const next: Entry = { files: [], dirs: [] }
try { try {
if (isGlobalHome) { if (isGlobalHome) {
const dirs = new Set<string>() const dirs = new Set<string>()
const protectedNames = Protected.names() const protectedNames = Protected.names()
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = await fs.promises const top = await fs.promises
.readdir(instance.directory, { withFileTypes: true }) .readdir(instance.directory, { withFileTypes: true })
.catch(() => [] as fs.Dirent[]) .catch(() => [] as fs.Dirent[])
@@ -419,7 +402,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
next.dirs = Array.from(dirs).toSorted() next.dirs = Array.from(dirs).toSorted()
} else { } else {
const set = new Set<string>() const seen = new Set<string>()
for await (const file of Ripgrep.files({ cwd: instance.directory })) { for await (const file of Ripgrep.files({ cwd: instance.directory })) {
next.files.push(file) next.files.push(file)
let current = file let current = file
@@ -428,8 +411,8 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
if (dir === ".") break if (dir === ".") break
if (dir === current) break if (dir === current) break
current = dir current = dir
if (set.has(dir)) continue if (seen.has(dir)) continue
set.add(dir) seen.add(dir)
next.dirs.push(dir + "/") next.dirs.push(dir + "/")
} }
} }
@@ -447,11 +430,11 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
return cache return cache
} }
const init = Effect.fn("FileService.init")(function* () { const init = Effect.fn("File.init")(function* () {
yield* Effect.promise(() => kick()) yield* Effect.promise(() => kick())
}) })
const status = Effect.fn("FileService.status")(function* () { const status = Effect.fn("File.status")(function* () {
if (instance.project.vcs !== "git") return [] if (instance.project.vcs !== "git") return []
return yield* Effect.promise(async () => { return yield* Effect.promise(async () => {
@@ -461,14 +444,13 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
}) })
).text() ).text()
const changedFiles: File.Info[] = [] const changed: File.Info[] = []
if (diffOutput.trim()) { if (diffOutput.trim()) {
const lines = diffOutput.trim().split("\n") for (const line of diffOutput.trim().split("\n")) {
for (const line of lines) { const [added, removed, file] = line.split("\t")
const [added, removed, filepath] = line.split("\t") changed.push({
changedFiles.push({ path: file,
path: filepath,
added: added === "-" ? 0 : parseInt(added, 10), added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10), removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified", status: "modified",
@@ -494,14 +476,12 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
).text() ).text()
if (untrackedOutput.trim()) { if (untrackedOutput.trim()) {
const untrackedFiles = untrackedOutput.trim().split("\n") for (const file of untrackedOutput.trim().split("\n")) {
for (const filepath of untrackedFiles) {
try { try {
const content = await Filesystem.readText(path.join(instance.directory, filepath)) const content = await Filesystem.readText(path.join(instance.directory, file))
const lines = content.split("\n").length changed.push({
changedFiles.push({ path: file,
path: filepath, added: content.split("\n").length,
added: lines,
removed: 0, removed: 0,
status: "added", status: "added",
}) })
@@ -511,7 +491,6 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
} }
} }
// Get deleted files
const deletedOutput = ( const deletedOutput = (
await git( await git(
[ [
@@ -531,50 +510,51 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
).text() ).text()
if (deletedOutput.trim()) { if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n") for (const file of deletedOutput.trim().split("\n")) {
for (const filepath of deletedFiles) { changed.push({
changedFiles.push({ path: file,
path: filepath,
added: 0, added: 0,
removed: 0, // Could get original line count but would require another git command removed: 0,
status: "deleted", status: "deleted",
}) })
} }
} }
return changedFiles.map((x) => { return changed.map((item) => {
const full = path.isAbsolute(x.path) ? x.path : path.join(instance.directory, x.path) const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
return { return {
...x, ...item,
path: path.relative(instance.directory, full), path: path.relative(instance.directory, full),
} }
}) })
}) })
}) })
const read = Effect.fn("FileService.read")(function* (file: string) { const read = Effect.fn("File.read")(function* (file: string) {
return yield* Effect.promise(async (): Promise<File.Content> => { return yield* Effect.promise(async (): Promise<File.Content> => {
using _ = log.time("read", { file }) using _ = log.time("read", { file })
const full = path.join(instance.directory, file) const full = path.join(instance.directory, file)
if (!Instance.containsPath(full)) { if (!Instance.containsPath(full)) {
throw new Error(`Access denied: path escapes project directory`) throw new Error("Access denied: path escapes project directory")
} }
// Fast path: check extension before any filesystem operations
if (isImageByExtension(file)) { if (isImageByExtension(file)) {
if (await Filesystem.exists(full)) { if (await Filesystem.exists(full)) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
const content = buffer.toString("base64") return {
const mimeType = getImageMimeType(file) type: "text",
return { type: "text", content, mimeType, encoding: "base64" } content: buffer.toString("base64"),
mimeType: getImageMimeType(file),
encoding: "base64",
}
} }
return { type: "text", content: "" } return { type: "text", content: "" }
} }
const text = isTextByExtension(file) || isTextByName(file) const knownText = isTextByExtension(file) || isTextByName(file)
if (isBinaryByExtension(file) && !text) { if (isBinaryByExtension(file) && !knownText) {
return { type: "binary", content: "" } return { type: "binary", content: "" }
} }
@@ -583,7 +563,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
} }
const mimeType = Filesystem.mimeType(full) const mimeType = Filesystem.mimeType(full)
const encode = text ? false : shouldEncode(mimeType) const encode = knownText ? false : shouldEncode(mimeType)
if (encode && !isImage(mimeType)) { if (encode && !isImage(mimeType)) {
return { type: "binary", content: "", mimeType } return { type: "binary", content: "", mimeType }
@@ -591,8 +571,12 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
if (encode) { if (encode) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
const content = buffer.toString("base64") return {
return { type: "text", content, mimeType, encoding: "base64" } type: "text",
content: buffer.toString("base64"),
mimeType,
encoding: "base64",
}
} }
const content = (await Filesystem.readText(full).catch(() => "")).trim() const content = (await Filesystem.readText(full).catch(() => "")).trim()
@@ -603,7 +587,9 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
).text() ).text()
if (!diff.trim()) { if (!diff.trim()) {
diff = ( diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: instance.directory }) await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
cwd: instance.directory,
})
).text() ).text()
} }
if (diff.trim()) { if (diff.trim()) {
@@ -612,64 +598,64 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
context: Infinity, context: Infinity,
ignoreWhitespace: true, ignoreWhitespace: true,
}) })
const diff = formatPatch(patch) return {
return { type: "text", content, patch, diff } type: "text",
content,
patch,
diff: formatPatch(patch),
}
} }
} }
return { type: "text", content } return { type: "text", content }
}) })
}) })
const list = Effect.fn("FileService.list")(function* (dir?: string) { const list = Effect.fn("File.list")(function* (dir?: string) {
return yield* Effect.promise(async () => { return yield* Effect.promise(async () => {
const exclude = [".git", ".DS_Store"] const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false let ignored = (_: string) => false
if (instance.project.vcs === "git") { if (instance.project.vcs === "git") {
const ig = ignore() const ig = ignore()
const gitignorePath = path.join(instance.project.worktree, ".gitignore") const gitignore = path.join(instance.project.worktree, ".gitignore")
if (await Filesystem.exists(gitignorePath)) { if (await Filesystem.exists(gitignore)) {
ig.add(await Filesystem.readText(gitignorePath)) ig.add(await Filesystem.readText(gitignore))
} }
const ignorePath = path.join(instance.project.worktree, ".ignore") const ignoreFile = path.join(instance.project.worktree, ".ignore")
if (await Filesystem.exists(ignorePath)) { if (await Filesystem.exists(ignoreFile)) {
ig.add(await Filesystem.readText(ignorePath)) ig.add(await Filesystem.readText(ignoreFile))
} }
ignored = ig.ignores.bind(ig) ignored = ig.ignores.bind(ig)
} }
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
if (!Instance.containsPath(resolved)) { if (!Instance.containsPath(resolved)) {
throw new Error(`Access denied: path escapes project directory`) throw new Error("Access denied: path escapes project directory")
} }
const nodes: File.Node[] = [] const nodes: File.Node[] = []
for (const entry of await fs.promises for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
.readdir(resolved, {
withFileTypes: true,
})
.catch(() => [])) {
if (exclude.includes(entry.name)) continue if (exclude.includes(entry.name)) continue
const fullPath = path.join(resolved, entry.name) const absolute = path.join(resolved, entry.name)
const relativePath = path.relative(instance.directory, fullPath) const file = path.relative(instance.directory, absolute)
const type = entry.isDirectory() ? "directory" : "file" const type = entry.isDirectory() ? "directory" : "file"
nodes.push({ nodes.push({
name: entry.name, name: entry.name,
path: relativePath, path: file,
absolute: fullPath, absolute,
type, type,
ignored: ignored(type === "directory" ? relativePath + "/" : relativePath), ignored: ignored(type === "directory" ? file + "/" : file),
}) })
} }
return nodes.sort((a, b) => { return nodes.sort((a, b) => {
if (a.type !== b.type) { if (a.type !== b.type) return a.type === "directory" ? -1 : 1
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
}) })
}) })
}) })
const search = Effect.fn("FileService.search")(function* (input: { const search = Effect.fn("File.search")(function* (input: {
query: string query: string
limit?: number limit?: number
dirs?: boolean dirs?: boolean
@@ -682,34 +668,19 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
log.info("search", { query, kind }) log.info("search", { query, kind })
const result = await getFiles() const result = await getFiles()
const hidden = (item: string) => {
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1)
}
const preferHidden = query.startsWith(".") || query.includes("/.") const preferHidden = query.startsWith(".") || query.includes("/.")
const sortHiddenLast = (items: string[]) => {
if (preferHidden) return items
const visible: string[] = []
const hiddenItems: string[] = []
for (const item of items) {
const isHidden = hidden(item)
if (isHidden) hiddenItems.push(item)
if (!isHidden) visible.push(item)
}
return [...visible, ...hiddenItems]
}
if (!query) { if (!query) {
if (kind === "file") return result.files.slice(0, limit) if (kind === "file") return result.files.slice(0, limit)
return sortHiddenLast(result.dirs.toSorted()).slice(0, limit) return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
} }
const items = const items =
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target) const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
log.info("search", { query, kind, results: output.length }) log.info("search", { query, kind, results: output.length })
return output return output
@@ -717,8 +688,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
}) })
log.info("init") log.info("init")
return Service.of({ init, status, read, list, search })
return FileService.of({ init, status, read, list, search })
}), }),
) )
} }
+76 -81
View File
@@ -1,115 +1,110 @@
import { Log } from "../util/log" import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
import { Flag } from "@/flag/flag"
import { Filesystem } from "../util/filesystem"
import { Effect, Layer, ServiceMap, Semaphore } from "effect"
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema" import type { SessionID } from "@/session/schema"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
const log = Log.create({ service: "file.time" }) export namespace FileTime {
const log = Log.create({ service: "file.time" })
export namespace FileTimeService { export type Stamp = {
export interface Service { readonly read: Date
readonly mtime: number | undefined
readonly ctime: number | undefined
readonly size: number | undefined
}
const stamp = Effect.fnUntraced(function* (file: string) {
const stat = Filesystem.stat(file)
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
return {
read: yield* DateTime.nowAsDate,
mtime: stat?.mtime?.getTime(),
ctime: stat?.ctime?.getTime(),
size,
}
})
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID)
if (value) return value
const next = new Map<string, Stamp>()
reads.set(sessionID, next)
return next
}
export interface Interface {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void> readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined> readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void> readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T> readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
} }
}
type Stamp = { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
readonly read: Date
readonly mtime: number | undefined
readonly ctime: number | undefined
readonly size: number | undefined
}
function stamp(file: string): Stamp { export const layer = Layer.effect(
const stat = Filesystem.stat(file) Service,
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
return {
read: new Date(),
mtime: stat?.mtime?.getTime(),
ctime: stat?.ctime?.getTime(),
size,
}
}
function session(reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) {
let value = reads.get(sessionID)
if (!value) {
value = new Map<string, Stamp>()
reads.set(sessionID, value)
}
return value
}
export class FileTimeService extends ServiceMap.Service<FileTimeService, FileTimeService.Service>()(
"@opencode/FileTime",
) {
static readonly layer = Layer.effect(
FileTimeService,
Effect.gen(function* () { Effect.gen(function* () {
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const reads = new Map<SessionID, Map<string, Stamp>>() const reads = new Map<SessionID, Map<string, Stamp>>()
const locks = new Map<string, Semaphore.Semaphore>() const locks = new Map<string, Semaphore.Semaphore>()
function getLock(filepath: string) { const getLock = (filepath: string) => {
let lock = locks.get(filepath) const lock = locks.get(filepath)
if (!lock) { if (lock) return lock
lock = Semaphore.makeUnsafe(1)
locks.set(filepath, lock) const next = Semaphore.makeUnsafe(1)
} locks.set(filepath, next)
return lock return next
} }
return FileTimeService.of({ const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
read: Effect.fn("FileTimeService.read")(function* (sessionID: SessionID, file: string) { log.info("read", { sessionID, file })
log.info("read", { sessionID, file }) session(reads, sessionID).set(file, yield* stamp(file))
session(reads, sessionID).set(file, stamp(file))
}),
get: Effect.fn("FileTimeService.get")(function* (sessionID: SessionID, file: string) {
return reads.get(sessionID)?.get(file)?.read
}),
assert: Effect.fn("FileTimeService.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return
const time = reads.get(sessionID)?.get(filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const next = stamp(filepath)
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
if (changed) {
throw new Error(
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
)
}
}),
withLock: Effect.fn("FileTimeService.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
const lock = getLock(filepath)
return yield* Effect.promise(fn).pipe(lock.withPermits(1))
}),
}) })
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
return reads.get(sessionID)?.get(file)?.read
})
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return
const time = reads.get(sessionID)?.get(filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const next = yield* stamp(filepath)
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
if (!changed) return
throw new Error(
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
)
})
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
})
return Service.of({ read, get, assert, withLock })
}), }),
) )
}
export namespace FileTime {
export function read(sessionID: SessionID, file: string) { export function read(sessionID: SessionID, file: string) {
return runPromiseInstance(FileTimeService.use((s) => s.read(sessionID, file))) return runPromiseInstance(Service.use((s) => s.read(sessionID, file)))
} }
export function get(sessionID: SessionID, file: string) { export function get(sessionID: SessionID, file: string) {
return runPromiseInstance(FileTimeService.use((s) => s.get(sessionID, file))) return runPromiseInstance(Service.use((s) => s.get(sessionID, file)))
} }
export async function assert(sessionID: SessionID, filepath: string) { export async function assert(sessionID: SessionID, filepath: string) {
return runPromiseInstance(FileTimeService.use((s) => s.assert(sessionID, filepath))) return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath)))
} }
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> { export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
return runPromiseInstance(FileTimeService.use((s) => s.withLock(filepath, fn))) return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn)))
} }
} }
+55 -69
View File
@@ -1,89 +1,76 @@
import { BusEvent } from "@/bus/bus-event" import { Cause, Effect, Layer, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { InstanceContext } from "@/effect/instance-context"
import { Instance } from "@/project/instance"
import z from "zod"
import { Log } from "../util/log"
import { FileIgnore } from "./ignore"
import { Config } from "../config/config"
import path from "path"
// @ts-ignore // @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper" import { createWrapper } from "@parcel/watcher/wrapper"
import { lazy } from "@/util/lazy"
import type ParcelWatcher from "@parcel/watcher" import type ParcelWatcher from "@parcel/watcher"
import { readdir } from "fs/promises" import { readdir } from "fs/promises"
import { git } from "@/util/git" import path from "path"
import { Protected } from "./protected" import z from "zod"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceContext } from "@/effect/instance-context"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Cause, Effect, Layer, ServiceMap } from "effect" import { Instance } from "@/project/instance"
import { git } from "@/util/git"
const SUBSCRIBE_TIMEOUT_MS = 10_000 import { lazy } from "@/util/lazy"
import { Config } from "../config/config"
import { FileIgnore } from "./ignore"
import { Protected } from "./protected"
import { Log } from "../util/log"
declare const OPENCODE_LIBC: string | undefined declare const OPENCODE_LIBC: string | undefined
const log = Log.create({ service: "file.watcher" })
const event = {
Updated: BusEvent.define(
"file.watcher.updated",
z.object({
file: z.string(),
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
}),
),
}
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
const binding = require(
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch (error) {
log.error("failed to load watcher binding", { error })
return
}
})
function getBackend() {
if (process.platform === "win32") return "windows"
if (process.platform === "darwin") return "fs-events"
if (process.platform === "linux") return "inotify"
}
export namespace FileWatcher { export namespace FileWatcher {
export const Event = event const log = Log.create({ service: "file.watcher" })
/** Whether the native @parcel/watcher binding is available on this platform. */ const SUBSCRIBE_TIMEOUT_MS = 10_000
export const hasNativeBinding = () => !!watcher()
}
const init = Effect.fn("FileWatcherService.init")(function* () {}) export const Event = {
Updated: BusEvent.define(
export namespace FileWatcherService { "file.watcher.updated",
export interface Service { z.object({
readonly init: () => Effect.Effect<void> file: z.string(),
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
}),
),
} }
}
export class FileWatcherService extends ServiceMap.Service<FileWatcherService, FileWatcherService.Service>()( const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
"@opencode/FileWatcher", try {
) { const binding = require(
static readonly layer = Layer.effect( `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
FileWatcherService, )
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch (error) {
log.error("failed to load watcher binding", { error })
return
}
})
function getBackend() {
if (process.platform === "win32") return "windows"
if (process.platform === "darwin") return "fs-events"
if (process.platform === "linux") return "inotify"
}
export const hasNativeBinding = () => !!watcher()
export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () { Effect.gen(function* () {
const instance = yield* InstanceContext const instance = yield* InstanceContext
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return FileWatcherService.of({ init }) if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
log.info("init", { directory: instance.directory }) log.info("init", { directory: instance.directory })
const backend = getBackend() const backend = getBackend()
if (!backend) { if (!backend) {
log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform }) log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
return FileWatcherService.of({ init }) return Service.of({})
} }
const w = watcher() const w = watcher()
if (!w) return FileWatcherService.of({ init }) if (!w) return Service.of({})
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend }) log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
@@ -93,9 +80,9 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => { const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
if (err) return if (err) return
for (const evt of evts) { for (const evt of evts) {
if (evt.type === "create") Bus.publish(event.Updated, { file: evt.path, event: "add" }) if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") Bus.publish(event.Updated, { file: evt.path, event: "change" }) if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") Bus.publish(event.Updated, { file: evt.path, event: "unlink" }) if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
} }
}) })
@@ -108,7 +95,6 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
Effect.timeout(SUBSCRIBE_TIMEOUT_MS), Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => { Effect.catchCause((cause) => {
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) }) log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
// Clean up a subscription that resolves after timeout
pending.then((s) => s.unsubscribe()).catch(() => {}) pending.then((s) => s.unsubscribe()).catch(() => {})
return Effect.void return Effect.void
}), }),
@@ -137,11 +123,11 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
} }
} }
return FileWatcherService.of({ init }) return Service.of({})
}).pipe( }).pipe(
Effect.catchCause((cause) => { Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) }) log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.succeed(FileWatcherService.of({ init })) return Effect.succeed(Service.of({}))
}), }),
), ),
) )
+35 -42
View File
@@ -1,21 +1,20 @@
import { Bus } from "../bus"
import { File } from "../file"
import { Log } from "../util/log"
import path from "path"
import z from "zod"
import * as Formatter from "./formatter"
import { Config } from "../config/config"
import { mergeDeep } from "remeda"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
import { InstanceContext } from "@/effect/instance-context"
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { InstanceContext } from "@/effect/instance-context"
const log = Log.create({ service: "format" }) import path from "path"
import { mergeDeep } from "remeda"
import z from "zod"
import { Bus } from "../bus"
import { Config } from "../config/config"
import { File } from "../file"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
import { Log } from "../util/log"
import * as Formatter from "./formatter"
export namespace Format { export namespace Format {
const log = Log.create({ service: "format" })
export const Status = z export const Status = z
.object({ .object({
name: z.string(), name: z.string(),
@@ -27,25 +26,14 @@ export namespace Format {
}) })
export type Status = z.infer<typeof Status> export type Status = z.infer<typeof Status>
export async function init() { export interface Interface {
return runPromiseInstance(FormatService.use((s) => s.init())) readonly status: () => Effect.Effect<Status[]>
} }
export async function status() { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
return runPromiseInstance(FormatService.use((s) => s.status()))
}
}
export namespace FormatService { export const layer = Layer.effect(
export interface Service { Service,
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Format.Status[]>
}
}
export class FormatService extends ServiceMap.Service<FormatService, FormatService.Service>()("@opencode/Format") {
static readonly layer = Layer.effect(
FormatService,
Effect.gen(function* () { Effect.gen(function* () {
const instance = yield* InstanceContext const instance = yield* InstanceContext
@@ -63,17 +51,19 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
delete formatters[name] delete formatters[name]
continue continue
} }
const result = mergeDeep(formatters[name] ?? {}, { const info = mergeDeep(formatters[name] ?? {}, {
command: [], command: [],
extensions: [], extensions: [],
...item, ...item,
}) as Formatter.Info })
if (result.command.length === 0) continue if (info.command.length === 0) continue
result.enabled = async () => true formatters[name] = {
result.name = name ...info,
formatters[name] = result name,
enabled: async () => true,
}
} }
} else { } else {
log.info("all formatters are disabled") log.info("all formatters are disabled")
@@ -120,11 +110,12 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
}, },
) )
const exit = await proc.exited const exit = await proc.exited
if (exit !== 0) if (exit !== 0) {
log.error("failed", { log.error("failed", {
command: item.command, command: item.command,
...item.environment, ...item.environment,
}) })
}
} catch (error) { } catch (error) {
log.error("failed to format file", { log.error("failed to format file", {
error, error,
@@ -140,10 +131,8 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
log.info("init") log.info("init")
const init = Effect.fn("FormatService.init")(function* () {}) const status = Effect.fn("Format.status")(function* () {
const result: Status[] = []
const status = Effect.fn("FormatService.status")(function* () {
const result: Format.Status[] = []
for (const formatter of Object.values(formatters)) { for (const formatter of Object.values(formatters)) {
const isOn = yield* Effect.promise(() => isEnabled(formatter)) const isOn = yield* Effect.promise(() => isEnabled(formatter))
result.push({ result.push({
@@ -155,7 +144,11 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
return result return result
}) })
return FormatService.of({ init, status }) return Service.of({ status })
}), }),
) )
export async function status() {
return runPromiseInstance(Service.use((s) => s.status()))
}
} }
@@ -11,121 +11,128 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import z from "zod" import z from "zod"
import { PermissionID } from "./schema" import { PermissionID } from "./schema"
const log = Log.create({ service: "permission" }) export namespace PermissionEffect {
const log = Log.create({ service: "permission" })
export const Action = z.enum(["allow", "deny", "ask"]).meta({ export const Action = z.enum(["allow", "deny", "ask"]).meta({
ref: "PermissionAction", ref: "PermissionAction",
})
export type Action = z.infer<typeof Action>
export const Rule = z
.object({
permission: z.string(),
pattern: z.string(),
action: Action,
}) })
.meta({ export type Action = z.infer<typeof Action>
ref: "PermissionRule",
export const Rule = z
.object({
permission: z.string(),
pattern: z.string(),
action: Action,
})
.meta({
ref: "PermissionRule",
})
export type Rule = z.infer<typeof Rule>
export const Ruleset = Rule.array().meta({
ref: "PermissionRuleset",
}) })
export type Rule = z.infer<typeof Rule> export type Ruleset = z.infer<typeof Ruleset>
export const Ruleset = Rule.array().meta({ export const Request = z
ref: "PermissionRuleset", .object({
}) id: PermissionID.zod,
export type Ruleset = z.infer<typeof Ruleset>
export const Request = z
.object({
id: PermissionID.zod,
sessionID: SessionID.zod,
permission: z.string(),
patterns: z.string().array(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({
ref: "PermissionRequest",
})
export type Request = z.infer<typeof Request>
export const Reply = z.enum(["once", "always", "reject"])
export type Reply = z.infer<typeof Reply>
export const Approval = z.object({
projectID: ProjectID.zod,
patterns: z.string().array(),
})
export const Event = {
Asked: BusEvent.define("permission.asked", Request),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
requestID: PermissionID.zod, permission: z.string(),
reply: Reply, patterns: z.string().array(),
}), metadata: z.record(z.string(), z.any()),
), always: z.string().array(),
} tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({
ref: "PermissionRequest",
})
export type Request = z.infer<typeof Request>
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) { export const Reply = z.enum(["once", "always", "reject"])
override get message() { export type Reply = z.infer<typeof Reply>
return "The user rejected permission to use this specific tool call."
export const Approval = z.object({
projectID: ProjectID.zod,
patterns: z.string().array(),
})
export const Event = {
Asked: BusEvent.define("permission.asked", Request),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: SessionID.zod,
requestID: PermissionID.zod,
reply: Reply,
}),
),
} }
}
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", { export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
feedback: Schema.String, override get message() {
}) { return "The user rejected permission to use this specific tool call."
override get message() { }
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
} }
}
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", { export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
ruleset: Schema.Any, feedback: Schema.String,
}) { }) {
override get message() { override get message() {
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}` return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
}
} }
}
export type PermissionError = DeniedError | RejectedError | CorrectedError export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
ruleset: Schema.Any,
}) {
override get message() {
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
}
}
interface PendingEntry { export type Error = DeniedError | RejectedError | CorrectedError
info: Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
export const AskInput = Request.partial({ id: true }).extend({ export const AskInput = Request.partial({ id: true }).extend({
ruleset: Ruleset, ruleset: Ruleset,
}) })
export const ReplyInput = z.object({ export const ReplyInput = z.object({
requestID: PermissionID.zod, requestID: PermissionID.zod,
reply: Reply, reply: Reply,
message: z.string().optional(), message: z.string().optional(),
}) })
export declare namespace PermissionService { export interface Interface {
export interface Api { readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, PermissionError>
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void> readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]> readonly list: () => Effect.Effect<Request[]>
} }
}
export class PermissionService extends ServiceMap.Service<PermissionService, PermissionService.Api>()( interface PendingEntry {
"@opencode/PermissionNext", info: Request
) { deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
static readonly layer = Layer.effect( }
PermissionService,
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
const rules = rulesets.flat()
log.info("evaluate", { permission, pattern, ruleset: rules })
const match = rules.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () { Effect.gen(function* () {
const { project } = yield* InstanceContext const { project } = yield* InstanceContext
const row = Database.use((db) => const row = Database.use((db) =>
@@ -134,7 +141,7 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
const pending = new Map<PermissionID, PendingEntry>() const pending = new Map<PermissionID, PendingEntry>()
const approved: Ruleset = row?.data ?? [] const approved: Ruleset = row?.data ?? []
const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer<typeof AskInput>) { const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
const { ruleset, ...request } = input const { ruleset, ...request } = input
let needsAsk = false let needsAsk = false
@@ -170,7 +177,7 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
) )
}) })
const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer<typeof ReplyInput>) { const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
const existing = pending.get(input.requestID) const existing = pending.get(input.requestID)
if (!existing) return if (!existing) return
@@ -225,27 +232,13 @@ export class PermissionService extends ServiceMap.Service<PermissionService, Per
}) })
yield* Deferred.succeed(item.deferred, undefined) yield* Deferred.succeed(item.deferred, undefined)
} }
// TODO: we don't save the permission ruleset to disk yet until there's
// UI to manage it
// db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
// .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
}) })
const list = Effect.fn("PermissionService.list")(function* () { const list = Effect.fn("Permission.list")(function* () {
return Array.from(pending.values(), (item) => item.info) return Array.from(pending.values(), (item) => item.info)
}) })
return PermissionService.of({ ask, reply, list }) return Service.of({ ask, reply, list })
}), }),
) )
} }
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
const merged = rulesets.flat()
log.info("evaluate", { permission, pattern, ruleset: merged })
const match = merged.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
@@ -3,7 +3,7 @@ import { Config } from "@/config/config"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { Wildcard } from "@/util/wildcard" import { Wildcard } from "@/util/wildcard"
import os from "os" import os from "os"
import * as S from "./service" import { PermissionEffect as S } from "./effect"
export namespace PermissionNext { export namespace PermissionNext {
function expand(pattern: string): string { function expand(pattern: string): string {
@@ -26,7 +26,7 @@ export namespace PermissionNext {
export type Reply = S.Reply export type Reply = S.Reply
export const Approval = S.Approval export const Approval = S.Approval
export const Event = S.Event export const Event = S.Event
export const Service = S.PermissionService export const Service = S.Service
export const RejectedError = S.RejectedError export const RejectedError = S.RejectedError
export const CorrectedError = S.CorrectedError export const CorrectedError = S.CorrectedError
export const DeniedError = S.DeniedError export const DeniedError = S.DeniedError
@@ -53,16 +53,14 @@ export namespace PermissionNext {
return rulesets.flat() return rulesets.flat()
} }
export const ask = fn(S.AskInput, async (input) => export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((service) => service.ask(input))))
runPromiseInstance(S.PermissionService.use((service) => service.ask(input))),
)
export const reply = fn(S.ReplyInput, async (input) => export const reply = fn(S.ReplyInput, async (input) =>
runPromiseInstance(S.PermissionService.use((service) => service.reply(input))), runPromiseInstance(S.Service.use((service) => service.reply(input))),
) )
export async function list() { export async function list() {
return runPromiseInstance(S.PermissionService.use((service) => service.list())) return runPromiseInstance(S.Service.use((service) => service.list()))
} }
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
+1 -12
View File
@@ -1,34 +1,23 @@
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { Format } from "../format"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { FileWatcherService } from "../file/watcher"
import { File } from "../file" import { File } from "../file"
import { Project } from "./project" import { Project } from "./project"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Command } from "../command" import { Command } from "../command"
import { Instance } from "./instance" import { Instance } from "./instance"
import { VcsService } from "./vcs"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next" import { ShareNext } from "@/share/share-next"
import { Snapshot } from "../snapshot"
import { Truncate } from "../tool/truncation"
import { runPromiseInstance } from "@/effect/runtime"
export async function InstanceBootstrap() { export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory }) Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init() await Plugin.init()
ShareNext.init() ShareNext.init()
await Format.init()
await LSP.init() await LSP.init()
await runPromiseInstance(FileWatcherService.use((service) => service.init()))
File.init() File.init()
await runPromiseInstance(VcsService.use((s) => s.init()))
Snapshot.init()
Truncate.init()
Bus.subscribe(Command.Event.Executed, async (payload) => { Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) { if (payload.properties.name === Command.Default.INIT) {
await Project.setInitialized(Instance.project.id) Project.setInitialized(Instance.project.id)
} }
}) })
} }
+164 -142
View File
@@ -1,163 +1,185 @@
import { Log } from "@/util/log" import { GlobalBus } from "@/bus/global";
import { Context } from "../util/context" import { disposeInstance } from "@/effect/instance-registry";
import { Project } from "./project" import { Filesystem } from "@/util/filesystem";
import { State } from "./state" import { iife } from "@/util/iife";
import { iife } from "@/util/iife" import { Log } from "@/util/log";
import { GlobalBus } from "@/bus/global" import { Context } from "../util/context";
import { Filesystem } from "@/util/filesystem" import { Project } from "./project";
import { disposeInstance } from "@/effect/instance-registry" import { State } from "./state";
interface Context { interface Context {
directory: string directory: string;
worktree: string worktree: string;
project: Project.Info project: Project.Info;
} }
const context = Context.create<Context>("instance") const context = Context.create<Context>("instance");
const cache = new Map<string, Promise<Context>>() const cache = new Map<string, Promise<Context>>();
const disposal = { const disposal = {
all: undefined as Promise<void> | undefined, all: undefined as Promise<void> | undefined,
} };
function emit(directory: string) { function emit(directory: string) {
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory, directory,
payload: { payload: {
type: "server.instance.disposed", type: "server.instance.disposed",
properties: { properties: {
directory, directory,
}, },
}, },
}) });
} }
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) { function boot(input: {
return iife(async () => { directory: string;
const ctx = init?: () => Promise<any>;
input.project && input.worktree project?: Project.Info;
? { worktree?: string;
directory: input.directory, }) {
worktree: input.worktree, return iife(async () => {
project: input.project, const ctx =
} input.project && input.worktree
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({ ? {
directory: input.directory, directory: input.directory,
worktree: sandbox, worktree: input.worktree,
project, project: input.project,
})) }
await context.provide(ctx, async () => { : await Project.fromDirectory(input.directory).then(
await input.init?.() ({ project, sandbox }) => ({
}) directory: input.directory,
return ctx worktree: sandbox,
}) project,
}),
);
await context.provide(ctx, async () => {
await input.init?.();
});
return ctx;
});
} }
function track(directory: string, next: Promise<Context>) { function track(directory: string, next: Promise<Context>) {
const task = next.catch((error) => { const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory) if (cache.get(directory) === task) cache.delete(directory);
throw error throw error;
}) });
cache.set(directory, task) cache.set(directory, task);
return task return task;
} }
export const Instance = { export const Instance = {
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> { async provide<R>(input: {
const directory = Filesystem.resolve(input.directory) directory: string;
let existing = cache.get(directory) init?: () => Promise<any>;
if (!existing) { fn: () => R;
Log.Default.info("creating instance", { directory }) }): Promise<R> {
existing = track( const directory = Filesystem.resolve(input.directory);
directory, let existing = cache.get(directory);
boot({ if (!existing) {
directory, Log.Default.info("creating instance", { directory });
init: input.init, existing = track(
}), directory,
) boot({
} directory,
const ctx = await existing init: input.init,
return context.provide(ctx, async () => { }),
return input.fn() );
}) }
}, const ctx = await existing;
get directory() { return context.provide(ctx, async () => {
return context.use().directory return input.fn();
}, });
get worktree() { },
return context.use().worktree get current() {
}, return context.use();
get project() { },
return context.use().project get directory() {
}, return context.use().directory;
/** },
* Check if a path is within the project boundary. get worktree() {
* Returns true if path is inside Instance.directory OR Instance.worktree. return context.use().worktree;
* Paths within the worktree but outside the working directory should not trigger external_directory permission. },
*/ get project() {
containsPath(filepath: string) { return context.use().project;
if (Filesystem.contains(Instance.directory, filepath)) return true },
// Non-git projects set worktree to "/" which would match ANY absolute path. /**
// Skip worktree check in this case to preserve external_directory permissions. * Check if a path is within the project boundary.
if (Instance.worktree === "/") return false * Returns true if path is inside Instance.directory OR Instance.worktree.
return Filesystem.contains(Instance.worktree, filepath) * Paths within the worktree but outside the working directory should not trigger external_directory permission.
}, */
/** containsPath(filepath: string) {
* Captures the current instance ALS context and returns a wrapper that if (Filesystem.contains(Instance.directory, filepath)) return true;
* restores it when called. Use this for callbacks that fire outside the // Non-git projects set worktree to "/" which would match ANY absolute path.
* instance async context (native addons, event emitters, timers, etc.). // Skip worktree check in this case to preserve external_directory permissions.
*/ if (Instance.worktree === "/") return false;
bind<F extends (...args: any[]) => any>(fn: F): F { return Filesystem.contains(Instance.worktree, filepath);
const ctx = context.use() },
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F /**
}, * Captures the current instance ALS context and returns a wrapper that
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S { * restores it when called. Use this for callbacks that fire outside the
return State.create(() => Instance.directory, init, dispose) * instance async context (native addons, event emitters, timers, etc.).
}, */
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) { bind<F extends (...args: any[]) => any>(fn: F): F {
const directory = Filesystem.resolve(input.directory) const ctx = context.use();
Log.Default.info("reloading instance", { directory }) return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F;
await Promise.all([State.dispose(directory), disposeInstance(directory)]) },
cache.delete(directory) state<S>(
const next = track(directory, boot({ ...input, directory })) init: () => S,
emit(directory) dispose?: (state: Awaited<S>) => Promise<void>,
return await next ): () => S {
}, return State.create(() => Instance.directory, init, dispose);
async dispose() { },
const directory = Instance.directory async reload(input: {
Log.Default.info("disposing instance", { directory }) directory: string;
await Promise.all([State.dispose(directory), disposeInstance(directory)]) init?: () => Promise<any>;
cache.delete(directory) project?: Project.Info;
emit(directory) worktree?: string;
}, }) {
async disposeAll() { const directory = Filesystem.resolve(input.directory);
if (disposal.all) return disposal.all Log.Default.info("reloading instance", { directory });
await Promise.all([State.dispose(directory), disposeInstance(directory)]);
cache.delete(directory);
const next = track(directory, boot({ ...input, directory }));
emit(directory);
return await next;
},
async dispose() {
const directory = Instance.directory;
Log.Default.info("disposing instance", { directory });
await Promise.all([State.dispose(directory), disposeInstance(directory)]);
cache.delete(directory);
emit(directory);
},
async disposeAll() {
if (disposal.all) return disposal.all;
disposal.all = iife(async () => { disposal.all = iife(async () => {
Log.Default.info("disposing all instances") Log.Default.info("disposing all instances");
const entries = [...cache.entries()] const entries = [...cache.entries()];
for (const [key, value] of entries) { for (const [key, value] of entries) {
if (cache.get(key) !== value) continue if (cache.get(key) !== value) continue;
const ctx = await value.catch((error) => { const ctx = await value.catch((error) => {
Log.Default.warn("instance dispose failed", { key, error }) Log.Default.warn("instance dispose failed", { key, error });
return undefined return undefined;
}) });
if (!ctx) { if (!ctx) {
if (cache.get(key) === value) cache.delete(key) if (cache.get(key) === value) cache.delete(key);
continue continue;
} }
if (cache.get(key) !== value) continue if (cache.get(key) !== value) continue;
await context.provide(ctx, async () => { await context.provide(ctx, async () => {
await Instance.dispose() await Instance.dispose();
}) });
} }
}).finally(() => { }).finally(() => {
disposal.all = undefined disposal.all = undefined;
}) });
return disposal.all return disposal.all;
}, },
} };
+15 -19
View File
@@ -1,16 +1,16 @@
import { BusEvent } from "@/bus/bus-event" import { Effect, Layer, ServiceMap } from "effect"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import z from "zod" import { BusEvent } from "@/bus/bus-event"
import { Log } from "@/util/log"
import { Instance } from "./instance"
import { InstanceContext } from "@/effect/instance-context" import { InstanceContext } from "@/effect/instance-context"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { Log } from "@/util/log"
import { git } from "@/util/git" import { git } from "@/util/git"
import { Effect, Layer, ServiceMap } from "effect" import { Instance } from "./instance"
import z from "zod"
const log = Log.create({ service: "vcs" })
export namespace Vcs { export namespace Vcs {
const log = Log.create({ service: "vcs" })
export const Event = { export const Event = {
BranchUpdated: BusEvent.define( BranchUpdated: BusEvent.define(
"vcs.branch.updated", "vcs.branch.updated",
@@ -28,18 +28,15 @@ export namespace Vcs {
ref: "VcsInfo", ref: "VcsInfo",
}) })
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>
}
export namespace VcsService { export interface Interface {
export interface Service {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined> readonly branch: () => Effect.Effect<string | undefined>
} }
}
export class VcsService extends ServiceMap.Service<VcsService, VcsService.Service>()("@opencode/Vcs") { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
static readonly layer = Layer.effect(
VcsService, export const layer = Layer.effect(
Service,
Effect.gen(function* () { Effect.gen(function* () {
const instance = yield* InstanceContext const instance = yield* InstanceContext
let current: string | undefined let current: string | undefined
@@ -65,7 +62,7 @@ export class VcsService extends ServiceMap.Service<VcsService, VcsService.Servic
if (next !== current) { if (next !== current) {
log.info("branch changed", { from: current, to: next }) log.info("branch changed", { from: current, to: next })
current = next current = next
Bus.publish(Vcs.Event.BranchUpdated, { branch: next }) Bus.publish(Event.BranchUpdated, { branch: next })
} }
}), }),
) )
@@ -73,9 +70,8 @@ export class VcsService extends ServiceMap.Service<VcsService, VcsService.Servic
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
} }
return VcsService.of({ return Service.of({
init: Effect.fn("VcsService.init")(function* () {}), branch: Effect.fn("Vcs.branch")(function* () {
branch: Effect.fn("VcsService.branch")(function* () {
return current return current
}), }),
}) })
@@ -1,73 +1,67 @@
import type { AuthOuathResult } from "@opencode-ai/plugin" import type { AuthOuathResult } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import * as Auth from "@/auth/service"
import { ProviderID } from "./schema"
import { Effect, Layer, Record, ServiceMap, Struct } from "effect" import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
import { filter, fromEntries, map, pipe } from "remeda" import { filter, fromEntries, map, pipe } from "remeda"
import z from "zod" import z from "zod"
import * as Auth from "@/auth/effect"
import { ProviderID } from "./schema"
export const Method = z export namespace ProviderAuthEffect {
.object({ export const Method = z
type: z.union([z.literal("oauth"), z.literal("api")]), .object({
label: z.string(), type: z.union([z.literal("oauth"), z.literal("api")]),
}) label: z.string(),
.meta({ })
ref: "ProviderAuthMethod", .meta({
}) ref: "ProviderAuthMethod",
export type Method = z.infer<typeof Method> })
export type Method = z.infer<typeof Method>
export const Authorization = z export const Authorization = z
.object({ .object({
url: z.string(), url: z.string(),
method: z.union([z.literal("auto"), z.literal("code")]), method: z.union([z.literal("auto"), z.literal("code")]),
instructions: z.string(), instructions: z.string(),
}) })
.meta({ .meta({
ref: "ProviderAuthAuthorization", ref: "ProviderAuthAuthorization",
}) })
export type Authorization = z.infer<typeof Authorization> export type Authorization = z.infer<typeof Authorization>
export const OauthMissing = NamedError.create( export const OauthMissing = NamedError.create(
"ProviderAuthOauthMissing", "ProviderAuthOauthMissing",
z.object({ z.object({
providerID: ProviderID.zod, providerID: ProviderID.zod,
}), }),
) )
export const OauthCodeMissing = NamedError.create( export const OauthCodeMissing = NamedError.create(
"ProviderAuthOauthCodeMissing", "ProviderAuthOauthCodeMissing",
z.object({ z.object({
providerID: ProviderID.zod, providerID: ProviderID.zod,
}), }),
) )
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({})) export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
export type ProviderAuthError = export type Error =
| Auth.AuthServiceError | Auth.AuthEffect.AuthServiceError
| InstanceType<typeof OauthMissing> | InstanceType<typeof OauthMissing>
| InstanceType<typeof OauthCodeMissing> | InstanceType<typeof OauthCodeMissing>
| InstanceType<typeof OauthCallbackFailed> | InstanceType<typeof OauthCallbackFailed>
export namespace ProviderAuthService { export interface Interface {
export interface Service {
readonly methods: () => Effect.Effect<Record<string, Method[]>> readonly methods: () => Effect.Effect<Record<string, Method[]>>
readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined> readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined>
readonly callback: (input: { readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
providerID: ProviderID
method: number
code?: string
}) => Effect.Effect<void, ProviderAuthError>
} }
}
export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService, ProviderAuthService.Service>()( export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
"@opencode/ProviderAuth",
) { export const layer = Layer.effect(
static readonly layer = Layer.effect( Service,
ProviderAuthService,
Effect.gen(function* () { Effect.gen(function* () {
const auth = yield* Auth.AuthService const auth = yield* Auth.AuthEffect.Service
const hooks = yield* Effect.promise(async () => { const hooks = yield* Effect.promise(async () => {
const mod = await import("../plugin") const mod = await import("../plugin")
return pipe( return pipe(
@@ -79,11 +73,11 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
}) })
const pending = new Map<ProviderID, AuthOuathResult>() const pending = new Map<ProviderID, AuthOuathResult>()
const methods = Effect.fn("ProviderAuthService.methods")(function* () { const methods = Effect.fn("ProviderAuth.methods")(function* () {
return Record.map(hooks, (item) => item.methods.map((method): Method => Struct.pick(method, ["type", "label"]))) return Record.map(hooks, (item) => item.methods.map((method): Method => Struct.pick(method, ["type", "label"])))
}) })
const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: { const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
providerID: ProviderID providerID: ProviderID
method: number method: number
}) { }) {
@@ -98,15 +92,16 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
} }
}) })
const callback = Effect.fn("ProviderAuthService.callback")(function* (input: { const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
providerID: ProviderID providerID: ProviderID
method: number method: number
code?: string code?: string
}) { }) {
const match = pending.get(input.providerID) const match = pending.get(input.providerID)
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
if (match.method === "code" && !input.code) if (match.method === "code" && !input.code) {
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID })) return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
}
const result = yield* Effect.promise(() => const result = yield* Effect.promise(() =>
match.method === "code" ? match.callback(input.code!) : match.callback(), match.method === "code" ? match.callback(input.code!) : match.callback(),
@@ -131,13 +126,9 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
} }
}) })
return ProviderAuthService.of({ return Service.of({ methods, authorize, callback })
methods,
authorize,
callback,
})
}), }),
) )
static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.defaultLayer))
} }
+4 -4
View File
@@ -2,7 +2,7 @@ import z from "zod"
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import * as S from "./auth-service" import { ProviderAuthEffect as S } from "./auth-effect"
import { ProviderID } from "./schema" import { ProviderID } from "./schema"
export namespace ProviderAuth { export namespace ProviderAuth {
@@ -10,7 +10,7 @@ export namespace ProviderAuth {
export type Method = S.Method export type Method = S.Method
export async function methods() { export async function methods() {
return runPromiseInstance(S.ProviderAuthService.use((service) => service.methods())) return runPromiseInstance(S.Service.use((service) => service.methods()))
} }
export const Authorization = S.Authorization export const Authorization = S.Authorization
@@ -22,7 +22,7 @@ export namespace ProviderAuth {
method: z.number(), method: z.number(),
}), }),
async (input): Promise<Authorization | undefined> => async (input): Promise<Authorization | undefined> =>
runPromiseInstance(S.ProviderAuthService.use((service) => service.authorize(input))), runPromiseInstance(S.Service.use((service) => service.authorize(input))),
) )
export const callback = fn( export const callback = fn(
@@ -31,7 +31,7 @@ export namespace ProviderAuth {
method: z.number(), method: z.number(),
code: z.string().optional(), code: z.string().optional(),
}), }),
async (input) => runPromiseInstance(S.ProviderAuthService.use((service) => service.callback(input))), async (input) => runPromiseInstance(S.Service.use((service) => service.callback(input))),
) )
export import OauthMissing = S.OauthMissing export import OauthMissing = S.OauthMissing
+168
View File
@@ -0,0 +1,168 @@
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { SessionID, MessageID } from "@/session/schema"
import { Log } from "@/util/log"
import z from "zod"
import { QuestionID } from "./schema"
export namespace QuestionEffect {
const log = Log.create({ service: "question" })
export const Option = z
.object({
label: z.string().describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({ ref: "QuestionOption" })
export type Option = z.infer<typeof Option>
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().describe("Very short label (max 30 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
})
.meta({ ref: "QuestionInfo" })
export type Info = z.infer<typeof Info>
export const Request = z
.object({
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({ ref: "QuestionRequest" })
export type Request = z.infer<typeof Request>
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
export type Answer = z.infer<typeof Answer>
export const Reply = z.object({
answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
})
export type Reply = z.infer<typeof Reply>
export const Event = {
Asked: BusEvent.define("question.asked", Request),
Replied: BusEvent.define(
"question.replied",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
answers: z.array(Answer),
}),
),
Rejected: BusEvent.define(
"question.rejected",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
}),
),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
override get message() {
return "The user dismissed this question"
}
}
export type Error = RejectedError
interface Pending {
info: Request
deferred: Deferred.Deferred<Answer[], RejectedError>
}
export interface Interface {
readonly ask: (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) => Effect.Effect<Answer[], RejectedError>
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Question") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const pending = new Map<QuestionID, Pending>()
const ask = Effect.fn("Question.ask")(function* (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) {
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
const deferred = yield* Deferred.make<Answer[], RejectedError>()
const info: Request = {
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
}
pending.set(id, { info, deferred })
Bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
const existing = pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
})
yield* Deferred.succeed(existing.deferred, input.answers)
})
const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) {
const existing = pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
}
pending.delete(requestID)
log.info("rejected", { requestID })
Bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
yield* Deferred.fail(existing.deferred, new RejectedError())
})
const list = Effect.fn("Question.list")(function* () {
return Array.from(pending.values(), (x) => x.info)
})
return Service.of({ ask, reply, reject, list })
}),
)
}
+17 -17
View File
@@ -1,39 +1,39 @@
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import * as S from "./service" import * as S from "./effect"
import type { QuestionID } from "./schema" import type { QuestionID } from "./schema"
import type { SessionID, MessageID } from "@/session/schema" import type { SessionID, MessageID } from "@/session/schema"
export namespace Question { export namespace Question {
export const Option = S.Option export const Option = S.QuestionEffect.Option
export type Option = S.Option export type Option = S.QuestionEffect.Option
export const Info = S.Info export const Info = S.QuestionEffect.Info
export type Info = S.Info export type Info = S.QuestionEffect.Info
export const Request = S.Request export const Request = S.QuestionEffect.Request
export type Request = S.Request export type Request = S.QuestionEffect.Request
export const Answer = S.Answer export const Answer = S.QuestionEffect.Answer
export type Answer = S.Answer export type Answer = S.QuestionEffect.Answer
export const Reply = S.Reply export const Reply = S.QuestionEffect.Reply
export type Reply = S.Reply export type Reply = S.QuestionEffect.Reply
export const Event = S.Event export const Event = S.QuestionEffect.Event
export const RejectedError = S.RejectedError export const RejectedError = S.QuestionEffect.RejectedError
export async function ask(input: { export async function ask(input: {
sessionID: SessionID sessionID: SessionID
questions: Info[] questions: Info[]
tool?: { messageID: MessageID; callID: string } tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> { }): Promise<Answer[]> {
return runPromiseInstance(S.QuestionService.use((service) => service.ask(input))) return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.ask(input)))
} }
export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> { export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise<void> {
return runPromiseInstance(S.QuestionService.use((service) => service.reply(input))) return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.reply(input)))
} }
export async function reject(requestID: QuestionID): Promise<void> { export async function reject(requestID: QuestionID): Promise<void> {
return runPromiseInstance(S.QuestionService.use((service) => service.reject(requestID))) return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.reject(requestID)))
} }
export async function list(): Promise<Request[]> { export async function list(): Promise<Request[]> {
return runPromiseInstance(S.QuestionService.use((service) => service.list())) return runPromiseInstance(S.QuestionEffect.Service.use((service) => service.list()))
} }
} }
-172
View File
@@ -1,172 +0,0 @@
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { SessionID, MessageID } from "@/session/schema"
import { Log } from "@/util/log"
import z from "zod"
import { QuestionID } from "./schema"
const log = Log.create({ service: "question" })
// --- Zod schemas (re-exported by facade) ---
export const Option = z
.object({
label: z.string().describe("Display text (1-5 words, concise)"),
description: z.string().describe("Explanation of choice"),
})
.meta({ ref: "QuestionOption" })
export type Option = z.infer<typeof Option>
export const Info = z
.object({
question: z.string().describe("Complete question"),
header: z.string().describe("Very short label (max 30 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
})
.meta({ ref: "QuestionInfo" })
export type Info = z.infer<typeof Info>
export const Request = z
.object({
id: QuestionID.zod,
sessionID: SessionID.zod,
questions: z.array(Info).describe("Questions to ask"),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({ ref: "QuestionRequest" })
export type Request = z.infer<typeof Request>
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
export type Answer = z.infer<typeof Answer>
export const Reply = z.object({
answers: z.array(Answer).describe("User answers in order of questions (each answer is an array of selected labels)"),
})
export type Reply = z.infer<typeof Reply>
export const Event = {
Asked: BusEvent.define("question.asked", Request),
Replied: BusEvent.define(
"question.replied",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
answers: z.array(Answer),
}),
),
Rejected: BusEvent.define(
"question.rejected",
z.object({
sessionID: SessionID.zod,
requestID: QuestionID.zod,
}),
),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
override get message() {
return "The user dismissed this question"
}
}
// --- Effect service ---
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<Answer[], RejectedError>
}
export namespace QuestionService {
export interface Service {
readonly ask: (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) => Effect.Effect<Answer[], RejectedError>
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
}
}
export class QuestionService extends ServiceMap.Service<QuestionService, QuestionService.Service>()(
"@opencode/Question",
) {
static readonly layer = Layer.effect(
QuestionService,
Effect.gen(function* () {
const pending = new Map<QuestionID, PendingEntry>()
const ask = Effect.fn("QuestionService.ask")(function* (input: {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
}) {
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
const deferred = yield* Deferred.make<Answer[], RejectedError>()
const info: Request = {
id,
sessionID: input.sessionID,
questions: input.questions,
tool: input.tool,
}
pending.set(id, { info, deferred })
Bus.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
pending.delete(id)
}),
)
})
const reply = Effect.fn("QuestionService.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
const existing = pending.get(input.requestID)
if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID })
return
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers,
})
yield* Deferred.succeed(existing.deferred, input.answers)
})
const reject = Effect.fn("QuestionService.reject")(function* (requestID: QuestionID) {
const existing = pending.get(requestID)
if (!existing) {
log.warn("reject for unknown request", { requestID })
return
}
pending.delete(requestID)
log.info("rejected", { requestID })
Bus.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
yield* Deferred.fail(existing.deferred, new RejectedError())
})
const list = Effect.fn("QuestionService.list")(function* () {
return Array.from(pending.values(), (x) => x.info)
})
return QuestionService.of({ ask, reply, reject, list })
}),
)
}
-61
View File
@@ -1,61 +0,0 @@
import { Instance } from "../project/instance"
import { Log } from "../util/log"
export namespace Scheduler {
const log = Log.create({ service: "scheduler" })
export type Task = {
id: string
interval: number
run: () => Promise<void>
scope?: "instance" | "global"
}
type Timer = ReturnType<typeof setInterval>
type Entry = {
tasks: Map<string, Task>
timers: Map<string, Timer>
}
const create = (): Entry => {
const tasks = new Map<string, Task>()
const timers = new Map<string, Timer>()
return { tasks, timers }
}
const shared = create()
const state = Instance.state(
() => create(),
async (entry) => {
for (const timer of entry.timers.values()) {
clearInterval(timer)
}
entry.tasks.clear()
entry.timers.clear()
},
)
export function register(task: Task) {
const scope = task.scope ?? "instance"
const entry = scope === "global" ? shared : state()
const current = entry.timers.get(task.id)
if (current && scope === "global") return
if (current) clearInterval(current)
entry.tasks.set(task.id, task)
void run(task)
const timer = setInterval(() => {
void run(task)
}, task.interval)
timer.unref()
entry.timers.set(task.id, timer)
}
async function run(task: Task) {
log.info("run", { id: task.id })
await task.run().catch((error) => {
log.error("run failed", { id: task.id, error })
})
}
}
@@ -1,7 +1,7 @@
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod" import z from "zod"
import { PermissionNext } from "@/permission/next" import { PermissionNext } from "@/permission"
import { PermissionID } from "@/permission/schema" import { PermissionID } from "@/permission/schema"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
@@ -14,7 +14,7 @@ import { Todo } from "../../session/todo"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { PermissionNext } from "@/permission/next" import { PermissionNext } from "@/permission"
import { PermissionID } from "@/permission/schema" import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../error" import { errors } from "../error"
+2 -2
View File
@@ -14,7 +14,7 @@ import { LSP } from "../lsp"
import { Format } from "../format" import { Format } from "../format"
import { TuiRoutes } from "./routes/tui" import { TuiRoutes } from "./routes/tui"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Vcs, VcsService } from "../project/vcs" import { Vcs } from "../project/vcs"
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { Skill } from "../skill/skill" import { Skill } from "../skill/skill"
@@ -331,7 +331,7 @@ export namespace Server {
}, },
}), }),
async (c) => { async (c) => {
const branch = await runPromiseInstance(VcsService.use((s) => s.branch())) const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
return c.json({ return c.json({
branch, branch,
}) })
+1 -1
View File
@@ -28,7 +28,7 @@ import { SessionID, MessageID, PartID } from "./schema"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { PermissionNext } from "@/permission/next" import { PermissionNext } from "@/permission"
import { Global } from "@/global" import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider" import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
+1 -1
View File
@@ -20,7 +20,7 @@ import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { SystemPrompt } from "./system" import { SystemPrompt } from "./system"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { PermissionNext } from "@/permission/next" import { PermissionNext } from "@/permission"
import { Auth } from "@/auth" import { Auth } from "@/auth"
export namespace LLM { export namespace LLM {
+1 -1
View File
@@ -12,7 +12,7 @@ import type { Provider } from "@/provider/provider"
import { LLM } from "./llm" import { LLM } from "./llm"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction" import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission/next" import { PermissionNext } from "@/permission"
import { Question } from "@/question" import { Question } from "@/question"
import { PartID } from "./schema" import { PartID } from "./schema"
import type { SessionID, MessageID } from "./schema" import type { SessionID, MessageID } from "./schema"
+2 -2
View File
@@ -41,12 +41,12 @@ import { fn } from "@/util/fn"
import { SessionProcessor } from "./processor" import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task" import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool" import { Tool } from "@/tool/tool"
import { PermissionNext } from "@/permission/next" import { PermissionNext } from "@/permission"
import { SessionStatus } from "./status" import { SessionStatus } from "./status"
import { LLM } from "./llm" import { LLM } from "./llm"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncation" import { Truncate } from "@/tool/truncate"
import { decodeDataUrl } from "@/util/data-url" import { decodeDataUrl } from "@/util/data-url"
// @ts-ignore // @ts-ignore
+1 -1
View File
@@ -2,7 +2,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlit
import { ProjectTable } from "../project/project.sql" import { ProjectTable } from "../project/project.sql"
import type { MessageV2 } from "./message-v2" import type { MessageV2 } from "./message-v2"
import type { Snapshot } from "../snapshot" import type { Snapshot } from "../snapshot"
import type { PermissionNext } from "../permission/next" import type { PermissionNext } from "../permission"
import type { ProjectID } from "../project/schema" import type { ProjectID } from "../project/schema"
import type { SessionID, MessageID, PartID } from "./schema" import type { SessionID, MessageID, PartID } from "./schema"
import type { WorkspaceID } from "../control-plane/schema" import type { WorkspaceID } from "../control-plane/schema"
+1 -1
View File
@@ -11,7 +11,7 @@ import PROMPT_CODEX from "./prompt/codex_header.txt"
import PROMPT_TRINITY from "./prompt/trinity.txt" import PROMPT_TRINITY from "./prompt/trinity.txt"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import type { Agent } from "@/agent/agent" import type { Agent } from "@/agent/agent"
import { PermissionNext } from "@/permission/next" import { PermissionNext } from "@/permission"
import { Skill } from "@/skill" import { Skill } from "@/skill"
export namespace SystemPrompt { export namespace SystemPrompt {
+92 -91
View File
@@ -1,116 +1,117 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node" import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect" import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { Global } from "../global" import { Global } from "../global"
import { Log } from "../util/log" import { Log } from "../util/log"
import { withTransientReadRetry } from "@/util/effect-http-client"
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({ export namespace Discovery {
name: Schema.String, const skillConcurrency = 4
files: Schema.Array(Schema.String), const fileConcurrency = 8
}) {}
class Index extends Schema.Class<Index>("Index")({ class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
skills: Schema.Array(IndexSkill), name: Schema.String,
}) {} files: Schema.Array(Schema.String),
}) {}
const skillConcurrency = 4 class Index extends Schema.Class<Index>("Index")({
const fileConcurrency = 8 skills: Schema.Array(IndexSkill),
}) {}
export namespace DiscoveryService { export interface Interface {
export interface Service {
readonly pull: (url: string) => Effect.Effect<string[]> readonly pull: (url: string) => Effect.Effect<string[]>
} }
}
export class DiscoveryService extends ServiceMap.Service<DiscoveryService, DiscoveryService.Service>()( export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
"@opencode/SkillDiscovery",
) {
static readonly layer = Layer.effect(
DiscoveryService,
Effect.gen(function* () {
const log = Log.create({ service: "skill-discovery" })
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const cache = path.join(Global.Path.cache, "skills")
const download = Effect.fn("DiscoveryService.download")(function* (url: string, dest: string) { export const layer: Layer.Layer<Service, never, FileSystem.FileSystem | Path.Path | HttpClient.HttpClient> =
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true Layer.effect(
Service,
Effect.gen(function* () {
const log = Log.create({ service: "skill-discovery" })
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const cache = path.join(Global.Path.cache, "skills")
return yield* HttpClientRequest.get(url).pipe( const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
http.execute, if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
Effect.flatMap((res) => res.arrayBuffer),
Effect.flatMap((body) =>
fs
.makeDirectory(path.dirname(dest), { recursive: true })
.pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
),
Effect.as(true),
Effect.catch((err) =>
Effect.sync(() => {
log.error("failed to download", { url, err })
return false
}),
),
)
})
const pull: DiscoveryService.Service["pull"] = Effect.fn("DiscoveryService.pull")(function* (url: string) { return yield* HttpClientRequest.get(url).pipe(
const base = url.endsWith("/") ? url : `${url}/` http.execute,
const index = new URL("index.json", base).href Effect.flatMap((res) => res.arrayBuffer),
const host = base.slice(0, -1) Effect.flatMap((body) =>
fs
log.info("fetching index", { url: index }) .makeDirectory(path.dirname(dest), { recursive: true })
.pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
const data = yield* HttpClientRequest.get(index).pipe( ),
HttpClientRequest.acceptJson, Effect.as(true),
http.execute, Effect.catch((err) =>
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)), Effect.sync(() => {
Effect.catch((err) => log.error("failed to download", { url, err })
Effect.sync(() => { return false
log.error("failed to fetch index", { url: index, err }) }),
return null ),
}), )
),
)
if (!data) return []
const list = data.skills.filter((skill) => {
if (!skill.files.includes("SKILL.md")) {
log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
return false
}
return true
}) })
const dirs = yield* Effect.forEach( const pull = Effect.fn("Discovery.pull")(function* (url: string) {
list, const base = url.endsWith("/") ? url : `${url}/`
(skill) => const index = new URL("index.json", base).href
Effect.gen(function* () { const host = base.slice(0, -1)
const root = path.join(cache, skill.name)
yield* Effect.forEach( log.info("fetching index", { url: index })
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
{ concurrency: fileConcurrency },
)
const md = path.join(root, "SKILL.md") const data = yield* HttpClientRequest.get(index).pipe(
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null HttpClientRequest.acceptJson,
}), http.execute,
{ concurrency: skillConcurrency }, Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
) Effect.catch((err) =>
Effect.sync(() => {
log.error("failed to fetch index", { url: index, err })
return null
}),
),
)
return dirs.filter((dir): dir is string => dir !== null) if (!data) return []
})
return DiscoveryService.of({ pull }) const list = data.skills.filter((skill) => {
}), if (!skill.files.includes("SKILL.md")) {
) log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
return false
}
return true
})
static readonly defaultLayer = DiscoveryService.layer.pipe( const dirs = yield* Effect.forEach(
list,
(skill) =>
Effect.gen(function* () {
const root = path.join(cache, skill.name)
yield* Effect.forEach(
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
{
concurrency: fileConcurrency,
},
)
const md = path.join(root, "SKILL.md")
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
}),
{ concurrency: skillConcurrency },
)
return dirs.filter((dir): dir is string => dir !== null)
})
return Service.of({ pull })
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeFileSystem.layer), Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer), Layer.provide(NodePath.layer),
+197 -209
View File
@@ -1,34 +1,30 @@
import z from "zod"
import path from "path"
import os from "os" import os from "os"
import { Config } from "../config/config" import path from "path"
import { Instance } from "../project/instance"
import { NamedError } from "@opencode-ai/util/error"
import { ConfigMarkdown } from "../config/markdown"
import { Log } from "../util/log"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Flag } from "@/flag/flag"
import { Bus } from "@/bus"
import { DiscoveryService } from "./discovery"
import { Glob } from "../util/glob"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import type { Agent } from "@/agent/agent" import z from "zod"
import { PermissionNext } from "@/permission/next"
import { InstanceContext } from "@/effect/instance-context"
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { NamedError } from "@opencode-ai/util/error"
import type { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { Flag } from "@/flag/flag"
const log = Log.create({ service: "skill" }) import { Global } from "@/global"
import { PermissionNext } from "@/permission"
// External skill directories to search for (project-level and global) import { Filesystem } from "@/util/filesystem"
// These follow the directory layout used by Claude Code and other agents. import { Config } from "../config/config"
const EXTERNAL_DIRS = [".claude", ".agents"] import { ConfigMarkdown } from "../config/markdown"
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" import { Glob } from "../util/glob"
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" import { Log } from "../util/log"
const SKILL_PATTERN = "**/SKILL.md" import { Discovery } from "./discovery"
export namespace Skill { export namespace Skill {
const log = Log.create({ service: "skill" })
const EXTERNAL_DIRS = [".claude", ".agents"]
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
const SKILL_PATTERN = "**/SKILL.md"
export const Info = z.object({ export const Info = z.object({
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
@@ -55,213 +51,205 @@ export namespace Skill {
}), }),
) )
type State = {
skills: Record<string, Info>
dirs: Set<string>
task?: Promise<void>
}
type Cache = State & {
ensure: () => Promise<void>
}
export interface Interface {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly all: () => Effect.Effect<Info[]>
readonly dirs: () => Effect.Effect<string[]>
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
}
const add = async (state: State, match: string) => {
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse skill ${match}`
const { Session } = await import("@/session")
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load skill", { skill: match, err })
return undefined
})
if (!md) return
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
if (!parsed.success) return
if (state.skills[parsed.data.name]) {
log.warn("duplicate skill name", {
name: parsed.data.name,
existing: state.skills[parsed.data.name].location,
duplicate: match,
})
}
state.dirs.add(path.dirname(match))
state.skills[parsed.data.name] = {
name: parsed.data.name,
description: parsed.data.description,
location: match,
content: md.content,
}
}
const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
return Glob.scan(pattern, {
cwd: root,
absolute: true,
include: "file",
symlink: true,
dot: opts?.dot,
})
.then((matches) => Promise.all(matches.map((match) => add(state, match))))
.catch((error) => {
if (!opts?.scope) throw error
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
})
}
// TODO: Migrate to Effect
const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
const state: State = {
skills: {},
dirs: new Set<string>(),
}
const load = async () => {
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
for (const dir of EXTERNAL_DIRS) {
const root = path.join(Global.Path.home, dir)
if (!(await Filesystem.isDir(root))) continue
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
}
for await (const root of Filesystem.up({
targets: EXTERNAL_DIRS,
start: instance.directory,
stop: instance.project.worktree,
})) {
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
}
}
for (const dir of await Config.directories()) {
await scan(state, dir, OPENCODE_SKILL_PATTERN)
}
const cfg = await Config.get()
for (const item of cfg.skills?.paths ?? []) {
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
if (!(await Filesystem.isDir(dir))) {
log.warn("skill path not found", { path: dir })
continue
}
await scan(state, dir, SKILL_PATTERN)
}
for (const url of cfg.skills?.urls ?? []) {
for (const dir of await Effect.runPromise(discovery.pull(url))) {
state.dirs.add(dir)
await scan(state, dir, SKILL_PATTERN)
}
}
log.info("init", { count: Object.keys(state.skills).length })
}
const ensure = () => {
if (state.task) return state.task
state.task = load().catch((err) => {
state.task = undefined
throw err
})
return state.task
}
return { ...state, ensure }
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const discovery = yield* Discovery.Service
const state = create(instance, discovery)
const get = Effect.fn("Skill.get")(function* (name: string) {
yield* Effect.promise(() => state.ensure())
return state.skills[name]
})
const all = Effect.fn("Skill.all")(function* () {
yield* Effect.promise(() => state.ensure())
return Object.values(state.skills)
})
const dirs = Effect.fn("Skill.dirs")(function* () {
yield* Effect.promise(() => state.ensure())
return Array.from(state.dirs)
})
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
yield* Effect.promise(() => state.ensure())
const list = Object.values(state.skills)
if (!agent) return list
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
})
return Service.of({ get, all, dirs, available })
}),
)
export const defaultLayer: Layer.Layer<Service, never, InstanceContext> = layer.pipe(
Layer.provide(Discovery.defaultLayer),
)
export async function get(name: string) { export async function get(name: string) {
return runPromiseInstance(SkillService.use((s) => s.get(name))) return runPromiseInstance(Service.use((skill) => skill.get(name)))
} }
export async function all() { export async function all() {
return runPromiseInstance(SkillService.use((s) => s.all())) return runPromiseInstance(Service.use((skill) => skill.all()))
} }
export async function dirs() { export async function dirs() {
return runPromiseInstance(SkillService.use((s) => s.dirs())) return runPromiseInstance(Service.use((skill) => skill.dirs()))
} }
export async function available(agent?: Agent.Info) { export async function available(agent?: Agent.Info) {
return runPromiseInstance(SkillService.use((s) => s.available(agent))) return runPromiseInstance(Service.use((skill) => skill.available(agent)))
} }
export function fmt(list: Info[], opts: { verbose: boolean }) { export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) { if (list.length === 0) return "No skills are currently available."
return "No skills are currently available."
}
if (opts.verbose) { if (opts.verbose) {
return [ return [
"<available_skills>", "<available_skills>",
...list.flatMap((skill) => [ ...list.flatMap((skill) => [
` <skill>`, " <skill>",
` <name>${skill.name}</name>`, ` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`, ` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`, ` <location>${pathToFileURL(skill.location).href}</location>`,
` </skill>`, " </skill>",
]), ]),
"</available_skills>", "</available_skills>",
].join("\n") ].join("\n")
} }
return ["## Available Skills", ...list.flatMap((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
} }
} }
export namespace SkillService {
export interface Service {
readonly get: (name: string) => Effect.Effect<Skill.Info | undefined>
readonly all: () => Effect.Effect<Skill.Info[]>
readonly dirs: () => Effect.Effect<string[]>
readonly available: (agent?: Agent.Info) => Effect.Effect<Skill.Info[]>
}
}
export class SkillService extends ServiceMap.Service<SkillService, SkillService.Service>()("@opencode/Skill") {
static readonly layer = Layer.effect(
SkillService,
Effect.gen(function* () {
const instance = yield* InstanceContext
const discovery = yield* DiscoveryService
const skills: Record<string, Skill.Info> = {}
const skillDirs = new Set<string>()
let task: Promise<void> | undefined
const addSkill = async (match: string) => {
const md = await ConfigMarkdown.parse(match).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse skill ${match}`
const { Session } = await import("@/session")
Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load skill", { skill: match, err })
return undefined
})
if (!md) return
const parsed = Skill.Info.pick({ name: true, description: true }).safeParse(md.data)
if (!parsed.success) return
// Warn on duplicate skill names
if (skills[parsed.data.name]) {
log.warn("duplicate skill name", {
name: parsed.data.name,
existing: skills[parsed.data.name].location,
duplicate: match,
})
}
skillDirs.add(path.dirname(match))
skills[parsed.data.name] = {
name: parsed.data.name,
description: parsed.data.description,
location: match,
content: md.content,
}
}
const scanExternal = async (root: string, scope: "global" | "project") => {
return Glob.scan(EXTERNAL_SKILL_PATTERN, {
cwd: root,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.then((matches) => Promise.all(matches.map(addSkill)))
.catch((error) => {
log.error(`failed to scan ${scope} skills`, { dir: root, error })
})
}
function ensureScanned() {
if (task) return task
task = (async () => {
// Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
// Load global (home) first, then project-level (so project-level overwrites)
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
for (const dir of EXTERNAL_DIRS) {
const root = path.join(Global.Path.home, dir)
if (!(await Filesystem.isDir(root))) continue
await scanExternal(root, "global")
}
for await (const root of Filesystem.up({
targets: EXTERNAL_DIRS,
start: instance.directory,
stop: instance.project.worktree,
})) {
await scanExternal(root, "project")
}
}
// Scan .opencode/skill/ directories
for (const dir of await Config.directories()) {
const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, {
cwd: dir,
absolute: true,
include: "file",
symlink: true,
})
for (const match of matches) {
await addSkill(match)
}
}
// Scan additional skill paths from config
const config = await Config.get()
for (const skillPath of config.skills?.paths ?? []) {
const expanded = skillPath.startsWith("~/") ? path.join(os.homedir(), skillPath.slice(2)) : skillPath
const resolved = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
if (!(await Filesystem.isDir(resolved))) {
log.warn("skill path not found", { path: resolved })
continue
}
const matches = await Glob.scan(SKILL_PATTERN, {
cwd: resolved,
absolute: true,
include: "file",
symlink: true,
})
for (const match of matches) {
await addSkill(match)
}
}
// Download and load skills from URLs
for (const url of config.skills?.urls ?? []) {
const list = await Effect.runPromise(discovery.pull(url))
for (const dir of list) {
skillDirs.add(dir)
const matches = await Glob.scan(SKILL_PATTERN, {
cwd: dir,
absolute: true,
include: "file",
symlink: true,
})
for (const match of matches) {
await addSkill(match)
}
}
}
log.info("init", { count: Object.keys(skills).length })
})().catch((err) => {
task = undefined
throw err
})
return task
}
return SkillService.of({
get: Effect.fn("SkillService.get")(function* (name: string) {
yield* Effect.promise(() => ensureScanned())
return skills[name]
}),
all: Effect.fn("SkillService.all")(function* () {
yield* Effect.promise(() => ensureScanned())
return Object.values(skills)
}),
dirs: Effect.fn("SkillService.dirs")(function* () {
yield* Effect.promise(() => ensureScanned())
return Array.from(skillDirs)
}),
available: Effect.fn("SkillService.available")(function* (agent?: Agent.Info) {
yield* Effect.promise(() => ensureScanned())
const list = Object.values(skills)
if (!agent) return list
return list.filter(
(skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny",
)
}),
})
}),
).pipe(Layer.provide(DiscoveryService.defaultLayer))
}
+315 -382
View File
@@ -1,257 +1,21 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path" import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
import { Global } from "../global"
import z from "zod" import z from "zod"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Instance } from "../project/instance" import { Global } from "../global"
import { Scheduler } from "../scheduler" import { Log } from "../util/log"
import { Process } from "@/util/process"
export namespace Snapshot { export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
const hour = 60 * 60 * 1000
const prune = "7.days"
function args(git: string, cmd: string[]) {
return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd]
}
export function init() {
Scheduler.register({
id: "snapshot.cleanup",
interval: hour,
run: cleanup,
scope: "instance",
})
}
export async function cleanup() {
if (Instance.project.vcs !== "git") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
const exists = await fs
.stat(git)
.then(() => true)
.catch(() => false)
if (!exists) return
const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], {
cwd: Instance.directory,
nothrow: true,
})
if (result.code !== 0) {
log.warn("cleanup failed", {
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
return
}
log.info("cleanup", { prune })
}
export async function track() {
if (Instance.project.vcs !== "git") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
if (await fs.mkdir(git, { recursive: true })) {
await Process.run(["git", "init"], {
env: {
...process.env,
GIT_DIR: git,
GIT_WORK_TREE: Instance.worktree,
},
nothrow: true,
})
// Configure git to not convert line endings on Windows
await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true })
log.info("initialized")
}
await add(git)
const hash = await Process.text(["git", ...args(git, ["write-tree"])], {
cwd: Instance.directory,
nothrow: true,
}).then((x) => x.text)
log.info("tracking", { hash, cwd: Instance.directory, git })
return hash.trim()
}
export const Patch = z.object({ export const Patch = z.object({
hash: z.string(), hash: z.string(),
files: z.string().array(), files: z.string().array(),
}) })
export type Patch = z.infer<typeof Patch> export type Patch = z.infer<typeof Patch>
export async function patch(hash: string): Promise<Patch> {
const git = gitdir()
await add(git)
const result = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)
// If git diff fails, return empty patch
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] }
}
const files = result.text
return {
hash,
files: files
.trim()
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
.map((x) => path.join(Instance.worktree, x).replaceAll("\\", "/")),
}
}
export async function restore(snapshot: string) {
log.info("restore", { commit: snapshot })
const git = gitdir()
const result = await Process.run(
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code === 0) {
const checkout = await Process.run(
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr.toString(),
stdout: checkout.stdout.toString(),
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
}
export async function revert(patches: Patch[]) {
const files = new Set<string>()
const git = gitdir()
for (const item of patches) {
for (const file of item.files) {
if (files.has(file)) continue
log.info("reverting", { file, hash: item.hash })
const result = await Process.run(
[
"git",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["checkout", item.hash, "--", file]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code !== 0) {
const relativePath = path.relative(Instance.worktree, file)
const checkTree = await Process.text(
[
"git",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["ls-tree", item.hash, "--", relativePath]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkTree.code === 0 && checkTree.text.trim()) {
log.info("file existed in snapshot but checkout failed, keeping", {
file,
})
} else {
log.info("file did not exist in snapshot, deleting", { file })
await fs.unlink(file).catch(() => {})
}
}
files.add(file)
}
}
}
export async function diff(hash: string) {
const git = gitdir()
await add(git)
const result = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", hash, "--", "."]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
return ""
}
return result.text.trim()
}
export const FileDiff = z export const FileDiff = z
.object({ .object({
file: z.string(), file: z.string(),
@@ -265,152 +29,321 @@ export namespace Snapshot {
ref: "FileDiff", ref: "FileDiff",
}) })
export type FileDiff = z.infer<typeof FileDiff> export type FileDiff = z.infer<typeof FileDiff>
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
const git = gitdir()
const result: FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">()
const statuses = await Process.text( export async function cleanup() {
[ return runPromiseInstance(Service.use((svc) => svc.cleanup()))
"git", }
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
).then((x) => x.text)
for (const line of statuses.trim().split("\n")) { export async function track() {
if (!line) continue return runPromiseInstance(Service.use((svc) => svc.track()))
const [code, file] = line.split("\t") }
if (!code || !file) continue
const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
status.set(file, kind)
}
for (const line of await Process.lines( export async function patch(hash: string) {
[ return runPromiseInstance(Service.use((svc) => svc.patch(hash)))
"git", }
"-c",
"core.autocrlf=false", export async function restore(snapshot: string) {
"-c", return runPromiseInstance(Service.use((svc) => svc.restore(snapshot)))
"core.longpaths=true", }
"-c",
"core.symlinks=true", export async function revert(patches: Patch[]) {
"-c", return runPromiseInstance(Service.use((svc) => svc.revert(patches)))
"core.quotepath=false", }
...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]),
], export async function diff(hash: string) {
{ return runPromiseInstance(Service.use((svc) => svc.diff(hash)))
cwd: Instance.directory, }
nothrow: true,
}, export async function diffFull(from: string, to: string) {
)) { return runPromiseInstance(Service.use((svc) => svc.diffFull(from, to)))
if (!line) continue }
const [additions, deletions, file] = line.split("\t")
const isBinaryFile = additions === "-" && deletions === "-" const log = Log.create({ service: "snapshot" })
const before = isBinaryFile const prune = "7.days"
? "" const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
: await Process.text( const cfg = ["-c", "core.autocrlf=false", ...core]
[ const quote = [...cfg, "-c", "core.quotepath=false"]
"git",
"-c", interface GitResult {
"core.autocrlf=false", readonly code: ChildProcessSpawner.ExitCode
"-c", readonly text: string
"core.longpaths=true", readonly stderr: string
"-c", }
"core.symlinks=true",
...args(git, ["show", `${from}:${file}`]), export interface Interface {
], readonly cleanup: () => Effect.Effect<void>
{ nothrow: true }, readonly track: () => Effect.Effect<string | undefined>
).then((x) => x.text) readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
const after = isBinaryFile readonly restore: (snapshot: string) => Effect.Effect<void>
? "" readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
: await Process.text( readonly diff: (hash: string) => Effect.Effect<string>
[ readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
"git", }
"-c",
"core.autocrlf=false", export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
"-c",
"core.longpaths=true", export const layer: Layer.Layer<
"-c", Service,
"core.symlinks=true", never,
...args(git, ["show", `${to}:${file}`]), InstanceContext | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
], > = Layer.effect(
{ nothrow: true }, Service,
).then((x) => x.text) Effect.gen(function* () {
const added = isBinaryFile ? 0 : parseInt(additions) const ctx = yield* InstanceContext
const deleted = isBinaryFile ? 0 : parseInt(deletions) const fs = yield* FileSystem.FileSystem
result.push({ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
file, const directory = ctx.directory
before, const worktree = ctx.worktree
after, const project = ctx.project
additions: Number.isFinite(added) ? added : 0, const gitdir = path.join(Global.Path.data, "snapshot", project.id)
deletions: Number.isFinite(deleted) ? deleted : 0,
status: status.get(file) ?? "modified", const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
const git = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const proc = ChildProcess.make("git", cmd, {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
const [text, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, text, stderr } satisfies GitResult
},
Effect.scoped,
Effect.catch((err) =>
Effect.succeed({
code: ChildProcessSpawner.ExitCode(1),
text: "",
stderr: String(err),
}),
),
)
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
const mkdir = (dir: string) => fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
const write = (file: string, text: string) => fs.writeFileString(file, text).pipe(Effect.orDie)
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
const enabled = Effect.fnUntraced(function* () {
if (project.vcs !== "git") return false
return (yield* Effect.promise(() => Config.get())).snapshot !== false
}) })
}
return result
}
function gitdir() { const excludes = Effect.fnUntraced(function* () {
const project = Instance.project const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
return path.join(Global.Path.data, "snapshot", project.id) cwd: worktree,
} })
const file = result.text.trim()
if (!file) return
if (!(yield* exists(file))) return
return file
})
async function add(git: string) { const sync = Effect.fnUntraced(function* () {
await syncExclude(git) const file = yield* excludes()
await Process.run( const target = path.join(gitdir, "info", "exclude")
[ yield* mkdir(path.join(gitdir, "info"))
"git", if (!file) {
"-c", yield* write(target, "")
"core.autocrlf=false", return
"-c", }
"core.longpaths=true", yield* write(target, yield* read(file))
"-c", })
"core.symlinks=true",
...args(git, ["add", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)
}
async function syncExclude(git: string) { const add = Effect.fnUntraced(function* () {
const file = await excludes() yield* sync()
const target = path.join(git, "info", "exclude") yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
await fs.mkdir(path.join(git, "info"), { recursive: true }) })
if (!file) {
await Filesystem.write(target, "")
return
}
const text = await Filesystem.readText(file).catch(() => "")
await Filesystem.write(target, text) const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
} if (!(yield* enabled())) return
if (!(yield* exists(gitdir))) return
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory })
if (result.code !== 0) {
log.warn("cleanup failed", {
exitCode: result.code,
stderr: result.stderr,
})
return
}
log.info("cleanup", { prune })
})
async function excludes() { const track = Effect.fn("Snapshot.track")(function* () {
const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { if (!(yield* enabled())) return
cwd: Instance.worktree, const existed = yield* exists(gitdir)
nothrow: true, yield* mkdir(gitdir)
}).then((x) => x.text) if (!existed) {
if (!file.trim()) return yield* git(["init"], {
const exists = await fs env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
.stat(file.trim()) })
.then(() => true) yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"])
.catch(() => false) yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"])
if (!exists) return yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"])
return file.trim() yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"])
} log.info("initialized")
}
yield* add()
const result = yield* git(args(["write-tree"]), { cwd: directory })
const hash = result.text.trim()
log.info("tracking", { hash, cwd: directory, git: gitdir })
return hash
})
const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
yield* add()
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], {
cwd: directory,
})
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] }
}
return {
hash,
files: result.text
.trim()
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
.map((x) => path.join(worktree, x).replaceAll("\\", "/")),
}
})
const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
log.info("restore", { commit: snapshot })
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
if (result.code === 0) {
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
exitCode: checkout.code,
stderr: checkout.stderr,
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr,
})
})
const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
const map = new Map(patches.flatMap((patch) => patch.files.map((file) => [file, patch] as const)))
const seen = new Set<string>()
for (const file of patches.flatMap((patch) => patch.files)) {
if (seen.has(file)) continue
const patch = map.get(file)
if (!patch) continue
log.info("reverting", { file, hash: patch.hash })
const result = yield* git([...core, ...args(["checkout", patch.hash, "--", file])], { cwd: worktree })
if (result.code !== 0) {
const rel = path.relative(worktree, file)
const tree = yield* git([...core, ...args(["ls-tree", patch.hash, "--", rel])], { cwd: worktree })
if (tree.code === 0 && tree.text.trim()) {
log.info("file existed in snapshot but checkout failed, keeping", { file })
} else {
log.info("file did not exist in snapshot, deleting", { file })
yield* remove(file)
}
}
seen.add(file)
}
})
const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
yield* add()
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
cwd: worktree,
})
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
exitCode: result.code,
stderr: result.stderr,
})
return ""
}
return result.text.trim()
})
const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
const result: Snapshot.FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">()
const statuses = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
{ cwd: directory },
)
for (const line of statuses.text.trim().split("\n")) {
if (!line) continue
const [code, file] = line.split("\t")
if (!code || !file) continue
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
}
const numstat = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
{
cwd: directory,
},
)
for (const line of numstat.text.trim().split("\n")) {
if (!line) continue
const [adds, dels, file] = line.split("\t")
if (!file) continue
const binary = adds === "-" && dels === "-"
const [before, after] = binary
? ["", ""]
: yield* Effect.all(
[
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
],
{ concurrency: 2 },
)
const additions = binary ? 0 : parseInt(adds)
const deletions = binary ? 0 : parseInt(dels)
result.push({
file,
before,
after,
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
status: status.get(file) ?? "modified",
})
}
return result
})
yield* cleanup().pipe(
Effect.catchCause((cause) => {
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
return Effect.void
}),
Effect.repeat(Schedule.spaced(Duration.hours(1))),
Effect.delay(Duration.minutes(1)),
Effect.forkScoped,
)
return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
} }
+1 -1
View File
@@ -15,7 +15,7 @@ import { Flag } from "@/flag/flag.ts"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity" import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncation" import { Truncate } from "./truncate"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
const MAX_METADATA_LENGTH = 30_000 const MAX_METADATA_LENGTH = 30_000
+1 -1
View File
@@ -26,7 +26,7 @@ import { CodeSearchTool } from "./codesearch"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { LspTool } from "./lsp" import { LspTool } from "./lsp"
import { Truncate } from "./truncation" import { Truncate } from "./truncate"
import { ApplyPatchTool } from "./apply_patch" import { ApplyPatchTool } from "./apply_patch"
import { Glob } from "../util/glob" import { Glob } from "../util/glob"
+1 -1
View File
@@ -10,7 +10,7 @@ import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { defer } from "@/util/defer" import { defer } from "@/util/defer"
import { Config } from "../config/config" import { Config } from "../config/config"
import { PermissionNext } from "@/permission/next" import { PermissionNext } from "@/permission"
const parameters = z.object({ const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"), description: z.string().describe("A short (3-5 words) description of the task"),
+2 -2
View File
@@ -1,9 +1,9 @@
import z from "zod" import z from "zod"
import type { MessageV2 } from "../session/message-v2" import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent" import type { Agent } from "../agent/agent"
import type { PermissionNext } from "../permission/next" import type { PermissionNext } from "../permission"
import type { SessionID, MessageID } from "../session/schema" import type { SessionID, MessageID } from "../session/schema"
import { Truncate } from "./truncation" import { Truncate } from "./truncate"
export namespace Tool { export namespace Tool {
interface Metadata { interface Metadata {
@@ -0,0 +1,140 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
import { PermissionEffect } from "../permission/effect"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { ToolID } from "./schema"
import { TRUNCATION_DIR } from "./truncation-dir"
export namespace TruncateEffect {
const log = Log.create({ service: "truncation" })
const RETENTION = Duration.days(7)
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export const DIR = TRUNCATION_DIR
export const GLOB = path.join(TRUNCATION_DIR, "*")
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
export interface Options {
maxLines?: number
maxBytes?: number
direction?: "head" | "tail"
}
function hasTaskTool(agent?: Agent.Info) {
if (!agent?.permission) return false
return PermissionEffect.evaluate("task", "*", agent.permission).action !== "deny"
}
export interface Interface {
readonly cleanup: () => Effect.Effect<void>
/**
* Returns output unchanged when it fits within the limits, otherwise writes the full text
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
*/
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
Effect.catch(() => Effect.succeed([])),
)
for (const entry of entries) {
if (Identifier.timestamp(entry) >= cutoff) continue
yield* fs.remove(path.join(TRUNCATION_DIR, entry)).pipe(Effect.catch(() => Effect.void))
}
})
const output = Effect.fn("Truncate.output")(function* (
text: string,
options: Options = {},
agent?: Agent.Info,
) {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
const direction = options.direction ?? "head"
const lines = text.split("\n")
const totalBytes = Buffer.byteLength(text, "utf-8")
if (lines.length <= maxLines && totalBytes <= maxBytes) {
return { content: text, truncated: false } as const
}
const out: string[] = []
let i = 0
let bytes = 0
let hitBytes = false
if (direction === "head") {
for (i = 0; i < lines.length && i < maxLines; i++) {
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.push(lines[i])
bytes += size
}
} else {
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.unshift(lines[i])
bytes += size
}
}
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
yield* fs.makeDirectory(TRUNCATION_DIR, { recursive: true }).pipe(Effect.orDie)
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
const hint = hasTaskTool(agent)
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
return {
content:
direction === "head"
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`,
truncated: true,
outputPath: file,
} as const
})
yield* cleanup().pipe(
Effect.catchCause((cause) => {
log.error("truncation cleanup failed", { cause: Cause.pretty(cause) })
return Effect.void
}),
Effect.repeat(Schedule.spaced(Duration.hours(1))),
Effect.delay(Duration.minutes(1)),
Effect.forkScoped,
)
return Service.of({ cleanup, output })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
}
+19
View File
@@ -0,0 +1,19 @@
import type { Agent } from "../agent/agent"
import { runtime } from "@/effect/runtime"
import { TruncateEffect as S } from "./truncate-effect"
export namespace Truncate {
export const MAX_LINES = S.MAX_LINES
export const MAX_BYTES = S.MAX_BYTES
export const DIR = S.DIR
export const GLOB = S.GLOB
export type Result = S.Result
export type Options = S.Options
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
return runtime.runPromise(S.Service.use((s) => s.output(text, options, agent)))
}
}
@@ -0,0 +1,4 @@
import path from "path"
import { Global } from "../global"
export const TRUNCATION_DIR = path.join(Global.Path.data, "tool-output")
-108
View File
@@ -1,108 +0,0 @@
import fs from "fs/promises"
import path from "path"
import { Global } from "../global"
import { Identifier } from "../id/id"
import { PermissionNext } from "../permission/next"
import type { Agent } from "../agent/agent"
import { Scheduler } from "../scheduler"
import { Filesystem } from "../util/filesystem"
import { Glob } from "../util/glob"
import { ToolID } from "./schema"
export namespace Truncate {
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export const DIR = path.join(Global.Path.data, "tool-output")
export const GLOB = path.join(DIR, "*")
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
const HOUR_MS = 60 * 60 * 1000
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
export interface Options {
maxLines?: number
maxBytes?: number
direction?: "head" | "tail"
}
export function init() {
Scheduler.register({
id: "tool.truncation.cleanup",
interval: HOUR_MS,
run: cleanup,
scope: "global",
})
}
export async function cleanup() {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
const entries = await Glob.scan("tool_*", { cwd: DIR, include: "file" }).catch(() => [] as string[])
for (const entry of entries) {
if (Identifier.timestamp(entry) >= cutoff) continue
await fs.unlink(path.join(DIR, entry)).catch(() => {})
}
}
function hasTaskTool(agent?: Agent.Info): boolean {
if (!agent?.permission) return false
const rule = PermissionNext.evaluate("task", "*", agent.permission)
return rule.action !== "deny"
}
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
const direction = options.direction ?? "head"
const lines = text.split("\n")
const totalBytes = Buffer.byteLength(text, "utf-8")
if (lines.length <= maxLines && totalBytes <= maxBytes) {
return { content: text, truncated: false }
}
const out: string[] = []
let i = 0
let bytes = 0
let hitBytes = false
if (direction === "head") {
for (i = 0; i < lines.length && i < maxLines; i++) {
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.push(lines[i])
bytes += size
}
} else {
for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.unshift(lines[i])
bytes += size
}
}
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
const id = ToolID.ascending()
const filepath = path.join(DIR, id)
await Filesystem.write(filepath, text)
const hint = hasTaskTool(agent)
? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
: `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
const message =
direction === "head"
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`
return { content: message, truncated: true, outputPath: filepath }
}
}
+13 -25
View File
@@ -4,7 +4,7 @@ import { Effect, Layer, Option } from "effect"
import { AccountRepo } from "../../src/account/repo" import { AccountRepo } from "../../src/account/repo"
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema" import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
import { Database } from "../../src/storage/db" import { Database } from "../../src/storage/db"
import { testEffect } from "../fixture/effect" import { testEffect } from "../lib/effect"
const truncate = Layer.effectDiscard( const truncate = Layer.effectDiscard(
Effect.sync(() => { Effect.sync(() => {
@@ -16,24 +16,21 @@ const truncate = Layer.effectDiscard(
const it = testEffect(Layer.merge(AccountRepo.layer, truncate)) const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
it.effect( it.effect("list returns empty when no accounts exist", () =>
"list returns empty when no accounts exist",
Effect.gen(function* () { Effect.gen(function* () {
const accounts = yield* AccountRepo.use((r) => r.list()) const accounts = yield* AccountRepo.use((r) => r.list())
expect(accounts).toEqual([]) expect(accounts).toEqual([])
}), }),
) )
it.effect( it.effect("active returns none when no accounts exist", () =>
"active returns none when no accounts exist",
Effect.gen(function* () { Effect.gen(function* () {
const active = yield* AccountRepo.use((r) => r.active()) const active = yield* AccountRepo.use((r) => r.active())
expect(Option.isNone(active)).toBe(true) expect(Option.isNone(active)).toBe(true)
}), }),
) )
it.effect( it.effect("persistAccount inserts and getRow retrieves", () =>
"persistAccount inserts and getRow retrieves",
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
yield* AccountRepo.use((r) => yield* AccountRepo.use((r) =>
@@ -59,8 +56,7 @@ it.effect(
}), }),
) )
it.effect( it.effect("persistAccount sets the active account and org", () =>
"persistAccount sets the active account and org",
Effect.gen(function* () { Effect.gen(function* () {
const id1 = AccountID.make("user-1") const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2") const id2 = AccountID.make("user-2")
@@ -97,8 +93,7 @@ it.effect(
}), }),
) )
it.effect( it.effect("list returns all accounts", () =>
"list returns all accounts",
Effect.gen(function* () { Effect.gen(function* () {
const id1 = AccountID.make("user-1") const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2") const id2 = AccountID.make("user-2")
@@ -133,8 +128,7 @@ it.effect(
}), }),
) )
it.effect( it.effect("remove deletes an account", () =>
"remove deletes an account",
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
@@ -157,8 +151,7 @@ it.effect(
}), }),
) )
it.effect( it.effect("use stores the selected org and marks the account active", () =>
"use stores the selected org and marks the account active",
Effect.gen(function* () { Effect.gen(function* () {
const id1 = AccountID.make("user-1") const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2") const id2 = AccountID.make("user-2")
@@ -198,8 +191,7 @@ it.effect(
}), }),
) )
it.effect( it.effect("persistToken updates token fields", () =>
"persistToken updates token fields",
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
@@ -233,8 +225,7 @@ it.effect(
}), }),
) )
it.effect( it.effect("persistToken with no expiry sets token_expiry to null", () =>
"persistToken with no expiry sets token_expiry to null",
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
@@ -264,8 +255,7 @@ it.effect(
}), }),
) )
it.effect( it.effect("persistAccount upserts on conflict", () =>
"persistAccount upserts on conflict",
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
@@ -305,8 +295,7 @@ it.effect(
}), }),
) )
it.effect( it.effect("remove clears active state when deleting the active account", () =>
"remove clears active state when deleting the active account",
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
@@ -329,8 +318,7 @@ it.effect(
}), }),
) )
it.effect( it.effect("getRow returns none for nonexistent account", () =>
"getRow returns none for nonexistent account",
Effect.gen(function* () { Effect.gen(function* () {
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope"))) const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
expect(Option.isNone(row)).toBe(true) expect(Option.isNone(row)).toBe(true)
+19 -25
View File
@@ -1,12 +1,12 @@
import { expect } from "bun:test" import { expect } from "bun:test"
import { Duration, Effect, Layer, Option, Ref, Schema } from "effect" import { Duration, Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AccountRepo } from "../../src/account/repo" import { AccountRepo } from "../../src/account/repo"
import { AccountService } from "../../src/account/service" import { AccountEffect } from "../../src/account/effect"
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema" import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
import { Database } from "../../src/storage/db" import { Database } from "../../src/storage/db"
import { testEffect } from "../fixture/effect" import { testEffect } from "../lib/effect"
const truncate = Layer.effectDiscard( const truncate = Layer.effectDiscard(
Effect.sync(() => { Effect.sync(() => {
@@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard(
const it = testEffect(Layer.merge(AccountRepo.layer, truncate)) const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
const live = (client: HttpClient.HttpClient) => const live = (client: HttpClient.HttpClient) =>
AccountService.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) => const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
HttpClientResponse.fromWeb( HttpClientResponse.fromWeb(
@@ -34,8 +34,7 @@ const encodeOrg = Schema.encodeSync(Org)
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name })) const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
it.effect( it.effect("orgsByAccount groups orgs per account", () =>
"orgsByAccount groups orgs per account",
Effect.gen(function* () { Effect.gen(function* () {
yield* AccountRepo.use((r) => yield* AccountRepo.use((r) =>
r.persistAccount({ r.persistAccount({
@@ -61,10 +60,10 @@ it.effect(
}), }),
) )
const seen = yield* Ref.make<string[]>([]) const seen: Array<string> = []
const client = HttpClient.make((req) => const client = HttpClient.make((req) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Ref.update(seen, (xs) => [...xs, `${req.method} ${req.url}`]) seen.push(`${req.method} ${req.url}`)
if (req.url === "https://one.example.com/api/orgs") { if (req.url === "https://one.example.com/api/orgs") {
return json(req, [org("org-1", "One")]) return json(req, [org("org-1", "One")])
@@ -78,21 +77,20 @@ it.effect(
}), }),
) )
const rows = yield* AccountService.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client))) const rows = yield* AccountEffect.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([ expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
[AccountID.make("user-1"), [OrgID.make("org-1")]], [AccountID.make("user-1"), [OrgID.make("org-1")]],
[AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]], [AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]],
]) ])
expect(yield* Ref.get(seen)).toEqual([ expect(seen).toEqual([
"GET https://one.example.com/api/orgs", "GET https://one.example.com/api/orgs",
"GET https://two.example.com/api/orgs", "GET https://two.example.com/api/orgs",
]) ])
}), }),
) )
it.effect( it.effect("token refresh persists the new token", () =>
"token refresh persists the new token",
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
@@ -120,7 +118,7 @@ it.effect(
), ),
) )
const token = yield* AccountService.use((s) => s.token(id)).pipe(Effect.provide(live(client))) const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(token)).toBeDefined() expect(Option.getOrThrow(token)).toBeDefined()
expect(String(Option.getOrThrow(token))).toBe("at_new") expect(String(Option.getOrThrow(token))).toBe("at_new")
@@ -133,8 +131,7 @@ it.effect(
}), }),
) )
it.effect( it.effect("config sends the selected org header", () =>
"config sends the selected org header",
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
@@ -150,13 +147,11 @@ it.effect(
}), }),
) )
const seen = yield* Ref.make<{ auth?: string; org?: string }>({}) const seen: { auth?: string; org?: string } = {}
const client = HttpClient.make((req) => const client = HttpClient.make((req) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Ref.set(seen, { seen.auth = req.headers.authorization
auth: req.headers.authorization, seen.org = req.headers["x-org-id"]
org: req.headers["x-org-id"],
})
if (req.url === "https://one.example.com/api/config") { if (req.url === "https://one.example.com/api/config") {
return json(req, { config: { theme: "light", seats: 5 } }) return json(req, { config: { theme: "light", seats: 5 } })
@@ -166,18 +161,17 @@ it.effect(
}), }),
) )
const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client))) const cfg = yield* AccountEffect.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 }) expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
expect(yield* Ref.get(seen)).toEqual({ expect(seen).toEqual({
auth: "Bearer at_1", auth: "Bearer at_1",
org: "org-9", org: "org-9",
}) })
}), }),
) )
it.effect( it.effect("poll stores the account and first org on success", () =>
"poll stores the account and first org on success",
Effect.gen(function* () { Effect.gen(function* () {
const login = new Login({ const login = new Login({
code: DeviceCode.make("device-code"), code: DeviceCode.make("device-code"),
@@ -205,7 +199,7 @@ it.effect(
), ),
) )
const res = yield* AccountService.use((s) => s.poll(login)).pipe(Effect.provide(live(client))) const res = yield* AccountEffect.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
expect(res._tag).toBe("PollSuccess") expect(res._tag).toBe("PollSuccess")
if (res._tag === "PollSuccess") { if (res._tag === "PollSuccess") {
+5 -5
View File
@@ -3,7 +3,7 @@ import path from "path"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { PermissionNext } from "../../src/permission/next" import { PermissionNext } from "../../src/permission"
// Helper to evaluate permission for a tool with wildcard pattern // Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined { function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
@@ -76,7 +76,7 @@ test("explore agent denies edit and write", async () => {
}) })
test("explore agent asks for external directories and allows Truncate.GLOB", async () => { test("explore agent asks for external directories and allows Truncate.GLOB", async () => {
const { Truncate } = await import("../../src/tool/truncation") const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir() await using tmp = await tmpdir()
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
@@ -463,7 +463,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a
}) })
test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => { test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => {
const { Truncate } = await import("../../src/tool/truncation") const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir({ await using tmp = await tmpdir({
config: { config: {
permission: { permission: {
@@ -483,7 +483,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
}) })
test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => { test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => {
const { Truncate } = await import("../../src/tool/truncation") const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir({ await using tmp = await tmpdir({
config: { config: {
agent: { agent: {
@@ -507,7 +507,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
}) })
test("explicit Truncate.GLOB deny is respected", async () => { test("explicit Truncate.GLOB deny is respected", async () => {
const { Truncate } = await import("../../src/tool/truncation") const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir({ await using tmp = await tmpdir({
config: { config: {
permission: { permission: {
+5 -5
View File
@@ -5,7 +5,7 @@ import path from "path"
import { Deferred, Effect, Fiber, Option } from "effect" import { Deferred, Effect, Fiber, Option } from "effect"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance" import { watcherConfigLayer, withServices } from "../fixture/instance"
import { FileWatcher, FileWatcherService } from "../../src/file/watcher" import { FileWatcher } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global" import { GlobalBus } from "../../src/bus/global"
@@ -19,13 +19,13 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } } type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
/** Run `body` with a live FileWatcherService. */ /** Run `body` with a live FileWatcher service. */
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) { function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
return withServices( return withServices(
directory, directory,
FileWatcherService.layer, FileWatcher.layer,
async (rt) => { async (rt) => {
await rt.runPromise(FileWatcherService.use((s) => s.init())) await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
await Effect.runPromise(ready(directory)) await Effect.runPromise(ready(directory))
await Effect.runPromise(body) await Effect.runPromise(body)
}, },
@@ -138,7 +138,7 @@ function ready(directory: string) {
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describeWatcher("FileWatcherService", () => { describeWatcher("FileWatcher", () => {
afterEach(() => Instance.disposeAll()) afterEach(() => Instance.disposeAll())
test("publishes root create, update, and delete events", async () => { test("publishes root create, update, and delete events", async () => {
-7
View File
@@ -1,7 +0,0 @@
import { test } from "bun:test"
import { Effect, Layer } from "effect"
export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => ({
effect: <A, E2>(name: string, value: Effect.Effect<A, E2, R>) =>
test(name, () => Effect.runPromise(value.pipe(Effect.provide(layer)))),
})
+38 -32
View File
@@ -1,14 +1,14 @@
import { ConfigProvider, Layer, ManagedRuntime } from "effect" import { ConfigProvider, Layer, ManagedRuntime } from "effect";
import { InstanceContext } from "../../src/effect/instance-context" import { InstanceContext } from "../../src/effect/instance-context";
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance";
/** ConfigProvider that enables the experimental file watcher. */ /** ConfigProvider that enables the experimental file watcher. */
export const watcherConfigLayer = ConfigProvider.layer( export const watcherConfigLayer = ConfigProvider.layer(
ConfigProvider.fromUnknown({ ConfigProvider.fromUnknown({
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
}), }),
) );
/** /**
* Boot an Instance with the given service layers and run `body` with * Boot an Instance with the given service layers and run `body` with
@@ -19,29 +19,35 @@ export const watcherConfigLayer = ConfigProvider.layer(
* Pass extra layers via `options.provide` (e.g. ConfigProvider.layer). * Pass extra layers via `options.provide` (e.g. ConfigProvider.layer).
*/ */
export function withServices<S>( export function withServices<S>(
directory: string, directory: string,
layer: Layer.Layer<S, any, InstanceContext>, layer: Layer.Layer<S, any, InstanceContext>,
body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>, body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>,
options?: { provide?: Layer.Layer<never>[] }, options?: { provide?: Layer.Layer<never>[] },
) { ) {
return Instance.provide({ return Instance.provide({
directory, directory,
fn: async () => { fn: async () => {
const ctx = Layer.sync(InstanceContext, () => const ctx = Layer.sync(InstanceContext, () =>
InstanceContext.of({ directory: Instance.directory, project: Instance.project }), InstanceContext.of({
) directory: Instance.directory,
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any worktree: Instance.worktree,
if (options?.provide) { project: Instance.project,
for (const l of options.provide) { }),
resolved = resolved.pipe(Layer.provide(l)) as any );
} let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(
} Layer.provide(ctx),
const rt = ManagedRuntime.make(resolved) ) as any;
try { if (options?.provide) {
await body(rt) for (const l of options.provide) {
} finally { resolved = resolved.pipe(Layer.provide(l)) as any;
await rt.dispose() }
} }
}, const rt = ManagedRuntime.make(resolved);
}) try {
await body(rt);
} finally {
await rt.dispose();
}
},
});
} }
+12 -11
View File
@@ -1,17 +1,18 @@
import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { withServices } from "../fixture/instance" import { withServices } from "../fixture/instance"
import { FormatService } from "../../src/format" import { Format } from "../../src/format"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
describe("FormatService", () => { describe("Format", () => {
afterEach(() => Instance.disposeAll()) afterEach(() => Instance.disposeAll())
test("status() returns built-in formatters when no config overrides", async () => { test("status() returns built-in formatters when no config overrides", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await withServices(tmp.path, FormatService.layer, async (rt) => { await withServices(tmp.path, Format.layer, async (rt) => {
const statuses = await rt.runPromise(FormatService.use((s) => s.status())) const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
expect(Array.isArray(statuses)).toBe(true) expect(Array.isArray(statuses)).toBe(true)
expect(statuses.length).toBeGreaterThan(0) expect(statuses.length).toBeGreaterThan(0)
@@ -32,8 +33,8 @@ describe("FormatService", () => {
config: { formatter: false }, config: { formatter: false },
}) })
await withServices(tmp.path, FormatService.layer, async (rt) => { await withServices(tmp.path, Format.layer, async (rt) => {
const statuses = await rt.runPromise(FormatService.use((s) => s.status())) const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
expect(statuses).toEqual([]) expect(statuses).toEqual([])
}) })
}) })
@@ -47,18 +48,18 @@ describe("FormatService", () => {
}, },
}) })
await withServices(tmp.path, FormatService.layer, async (rt) => { await withServices(tmp.path, Format.layer, async (rt) => {
const statuses = await rt.runPromise(FormatService.use((s) => s.status())) const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
const gofmt = statuses.find((s) => s.name === "gofmt") const gofmt = statuses.find((s) => s.name === "gofmt")
expect(gofmt).toBeUndefined() expect(gofmt).toBeUndefined()
}) })
}) })
test("init() completes without error", async () => { test("service initializes without error", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await withServices(tmp.path, FormatService.layer, async (rt) => { await withServices(tmp.path, Format.layer, async (rt) => {
await rt.runPromise(FormatService.use((s) => s.init())) await rt.runPromise(Format.Service.use(() => Effect.void))
}) })
}) })
}) })
+37
View File
@@ -0,0 +1,37 @@
import { test, type TestOptions } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import type * as Scope from "effect/Scope"
import * as TestConsole from "effect/testing/TestConsole"
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
const env = TestConsole.layer
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2, never>) =>
Effect.gen(function* () {
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
if (Exit.isFailure(exit)) {
for (const err of Cause.prettyErrors(exit.cause)) {
yield* Effect.logError(err)
}
}
return yield* exit
}).pipe(Effect.runPromise)
const make = <R, E>(layer: Layer.Layer<R, E, never>) => {
const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test(name, () => run(value, layer), opts)
effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.only(name, () => run(value, layer), opts)
effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.skip(name, () => run(value, layer), opts)
return { effect }
}
export const it = make(env)
export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => make(Layer.provideMerge(layer, env))
+10
View File
@@ -0,0 +1,10 @@
import path from "path"
import { Effect, FileSystem } from "effect"
export const writeFileStringScoped = Effect.fn("test.writeFileStringScoped")(function* (file: string, text: string) {
const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
yield* fs.writeFileString(file, text)
yield* Effect.addFinalizer(() => fs.remove(file, { force: true }).pipe(Effect.orDie))
return file
})
@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import { PermissionNext } from "../src/permission/next" import { PermissionNext } from "../src/permission"
import { Config } from "../src/config/config" import { Config } from "../src/config/config"
import { Instance } from "../src/project/instance" import { Instance } from "../src/project/instance"
import { tmpdir } from "./fixture/fixture" import { tmpdir } from "./fixture/fixture"
@@ -4,8 +4,8 @@ import { Effect } from "effect"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { runtime } from "../../src/effect/runtime" import { runtime } from "../../src/effect/runtime"
import { Instances } from "../../src/effect/instances" import { Instances } from "../../src/effect/instances"
import { PermissionNext } from "../../src/permission/next" import { PermissionNext } from "../../src/permission"
import * as S from "../../src/permission/service" import * as S from "../../src/permission/effect"
import { PermissionID } from "../../src/permission/schema" import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@@ -1005,7 +1005,7 @@ test("ask - abort should clear pending request", async () => {
fn: async () => { fn: async () => {
const ctl = new AbortController() const ctl = new AbortController()
const ask = runtime.runPromise( const ask = runtime.runPromise(
S.PermissionService.use((svc) => S.PermissionEffect.Service.use((svc) =>
svc.ask({ svc.ask({
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
+10 -10
View File
@@ -2,13 +2,13 @@ import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { Layer, ManagedRuntime } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance" import { watcherConfigLayer, withServices } from "../fixture/instance"
import { FileWatcher, FileWatcherService } from "../../src/file/watcher" import { FileWatcher } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global" import { GlobalBus } from "../../src/bus/global"
import { Vcs, VcsService } from "../../src/project/vcs" import { Vcs } from "../../src/project/vcs"
// Skip in CI — native @parcel/watcher binding needed // Skip in CI — native @parcel/watcher binding needed
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
@@ -19,14 +19,14 @@ const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe
function withVcs( function withVcs(
directory: string, directory: string,
body: (rt: ManagedRuntime.ManagedRuntime<FileWatcherService | VcsService, never>) => Promise<void>, body: (rt: ManagedRuntime.ManagedRuntime<FileWatcher.Service | Vcs.Service, never>) => Promise<void>,
) { ) {
return withServices( return withServices(
directory, directory,
Layer.merge(FileWatcherService.layer, VcsService.layer), Layer.merge(FileWatcher.layer, Vcs.layer),
async (rt) => { async (rt) => {
await rt.runPromise(FileWatcherService.use((s) => s.init())) await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
await rt.runPromise(VcsService.use((s) => s.init())) await rt.runPromise(Vcs.Service.use(() => Effect.void))
await Bun.sleep(200) await Bun.sleep(200)
await body(rt) await body(rt)
}, },
@@ -67,7 +67,7 @@ describeVcs("Vcs", () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
await withVcs(tmp.path, async (rt) => { await withVcs(tmp.path, async (rt) => {
const branch = await rt.runPromise(VcsService.use((s) => s.branch())) const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
expect(branch).toBeDefined() expect(branch).toBeDefined()
expect(typeof branch).toBe("string") expect(typeof branch).toBe("string")
}) })
@@ -77,7 +77,7 @@ describeVcs("Vcs", () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await withVcs(tmp.path, async (rt) => { await withVcs(tmp.path, async (rt) => {
const branch = await rt.runPromise(VcsService.use((s) => s.branch())) const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
expect(branch).toBeUndefined() expect(branch).toBeUndefined()
}) })
}) })
@@ -110,7 +110,7 @@ describeVcs("Vcs", () => {
await fs.writeFile(head, `ref: refs/heads/${branch}\n`) await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
await pending await pending
const current = await rt.runPromise(VcsService.use((s) => s.branch())) const current = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
expect(current).toBe(branch) expect(current).toBe(branch)
}) })
}) })
-73
View File
@@ -1,73 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Scheduler } from "../src/scheduler"
import { Instance } from "../src/project/instance"
import { tmpdir } from "./fixture/fixture"
describe("Scheduler.register", () => {
const hour = 60 * 60 * 1000
test("defaults to instance scope per directory", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const runs = { count: 0 }
const id = "scheduler.instance." + Math.random().toString(36).slice(2)
const task = {
id,
interval: hour,
run: async () => {
runs.count += 1
},
}
await Instance.provide({
directory: one.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(1)
await Instance.provide({
directory: two.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(2)
})
test("global scope runs once across instances", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const runs = { count: 0 }
const id = "scheduler.global." + Math.random().toString(36).slice(2)
const task = {
id,
interval: hour,
run: async () => {
runs.count += 1
},
scope: "global" as const,
}
await Instance.provide({
directory: one.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(1)
await Instance.provide({
directory: two.path,
fn: async () => {
Scheduler.register(task)
await Instance.dispose()
},
})
expect(runs.count).toBe(1)
})
})
@@ -1,6 +1,6 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test" import { describe, test, expect, beforeAll, afterAll } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { DiscoveryService } from "../../src/skill/discovery" import { Discovery } from "../../src/skill/discovery"
import { Global } from "../../src/global" import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { rm } from "fs/promises" import { rm } from "fs/promises"
@@ -48,7 +48,7 @@ afterAll(async () => {
describe("Discovery.pull", () => { describe("Discovery.pull", () => {
const pull = (url: string) => const pull = (url: string) =>
Effect.runPromise(DiscoveryService.use((s) => s.pull(url)).pipe(Effect.provide(DiscoveryService.defaultLayer))) Effect.runPromise(Discovery.Service.use((s) => s.pull(url)).pipe(Effect.provide(Discovery.defaultLayer)))
test("downloads skills from cloudflare url", async () => { test("downloads skills from cloudflare url", async () => {
const dirs = await pull(CLOUDFLARE_SKILLS_URL) const dirs = await pull(CLOUDFLARE_SKILLS_URL)
+2 -2
View File
@@ -5,8 +5,8 @@ import { BashTool } from "../../src/tool/bash"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import type { PermissionNext } from "../../src/permission/next" import type { PermissionNext } from "../../src/permission"
import { Truncate } from "../../src/tool/truncation" import { Truncate } from "../../src/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
const ctx = { const ctx = {
@@ -3,7 +3,7 @@ import path from "path"
import type { Tool } from "../../src/tool/tool" import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { assertExternalDirectory } from "../../src/tool/external-directory" import { assertExternalDirectory } from "../../src/tool/external-directory"
import type { PermissionNext } from "../../src/permission/next" import type { PermissionNext } from "../../src/permission"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
const baseCtx: Omit<Tool.Context, "ask"> = { const baseCtx: Omit<Tool.Context, "ask"> = {
+1 -1
View File
@@ -4,7 +4,7 @@ import { ReadTool } from "../../src/tool/read"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { PermissionNext } from "../../src/permission/next" import { PermissionNext } from "../../src/permission"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
+1 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import type { PermissionNext } from "../../src/permission/next" import type { PermissionNext } from "../../src/permission"
import type { Tool } from "../../src/tool/tool" import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill" import { SkillTool } from "../../src/tool/skill"
+21 -29
View File
@@ -1,9 +1,13 @@
import { describe, test, expect, afterAll } from "bun:test" import { describe, test, expect } from "bun:test"
import { Truncate } from "../../src/tool/truncation" import { NodeFileSystem } from "@effect/platform-node"
import { Effect, FileSystem, Layer } from "effect"
import { Truncate } from "../../src/tool/truncate"
import { TruncateEffect } from "../../src/tool/truncate-effect"
import { Identifier } from "../../src/id/id" import { Identifier } from "../../src/id/id"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import fs from "fs/promises"
import path from "path" import path from "path"
import { testEffect } from "../lib/effect"
import { writeFileStringScoped } from "../lib/filesystem"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
@@ -125,36 +129,24 @@ describe("Truncate", () => {
describe("cleanup", () => { describe("cleanup", () => {
const DAY_MS = 24 * 60 * 60 * 1000 const DAY_MS = 24 * 60 * 60 * 1000
let oldFile: string const it = testEffect(Layer.mergeAll(TruncateEffect.defaultLayer, NodeFileSystem.layer))
let recentFile: string
afterAll(async () => { it.effect("deletes files older than 7 days and preserves recent files", () =>
await fs.unlink(oldFile).catch(() => {}) Effect.gen(function* () {
await fs.unlink(recentFile).catch(() => {}) const fs = yield* FileSystem.FileSystem
})
test("deletes files older than 7 days and preserves recent files", async () => { yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
await fs.mkdir(Truncate.DIR, { recursive: true })
// Create an old file (10 days ago) const old = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 10 * DAY_MS))
const oldTimestamp = Date.now() - 10 * DAY_MS const recent = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 3 * DAY_MS))
const oldId = Identifier.create("tool", false, oldTimestamp)
oldFile = path.join(Truncate.DIR, oldId)
await Filesystem.write(oldFile, "old content")
// Create a recent file (3 days ago) yield* writeFileStringScoped(old, "old content")
const recentTimestamp = Date.now() - 3 * DAY_MS yield* writeFileStringScoped(recent, "recent content")
const recentId = Identifier.create("tool", false, recentTimestamp) yield* TruncateEffect.Service.use((s) => s.cleanup())
recentFile = path.join(Truncate.DIR, recentId)
await Filesystem.write(recentFile, "recent content")
await Truncate.cleanup() expect(yield* fs.exists(old)).toBe(false)
expect(yield* fs.exists(recent)).toBe(true)
// Old file should be deleted }),
expect(await Filesystem.exists(oldFile)).toBe(false) )
// Recent file should still exist
expect(await Filesystem.exists(recentFile)).toBe(true)
})
}) })
}) })