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
+6
View File
@@ -11,12 +11,14 @@ 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")
try {
await Instance.provide({ await Instance.provide({
directory: dir, directory: dir,
init: InstanceBootstrap, init: InstanceBootstrap,
@@ -51,6 +53,10 @@ const seed = async () => {
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()
}
+207 -237
View File
@@ -1,24 +1,111 @@
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"
import { Effect, Layer, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff"
import fs from "fs"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
import path from "path"
import z from "zod"
import { Global } from "../global"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
export namespace File {
export const Info = z
.object({
path: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.meta({
ref: "File",
})
export type Info = z.infer<typeof Info>
export const Node = z
.object({
name: z.string(),
path: z.string(),
absolute: z.string(),
type: z.enum(["file", "directory"]),
ignored: z.boolean(),
})
.meta({
ref: "FileNode",
})
export type Node = z.infer<typeof Node>
export const Content = z
.object({
type: z.enum(["text", "binary"]),
content: z.string(),
diff: z.string().optional(),
patch: z
.object({
oldFileName: z.string(),
newFileName: z.string(),
oldHeader: z.string().optional(),
newHeader: z.string().optional(),
hunks: z.array(
z.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
}),
),
index: z.string().optional(),
})
.optional(),
encoding: z.literal("base64").optional(),
mimeType: z.string().optional(),
})
.meta({
ref: "FileContent",
})
export type Content = z.infer<typeof Content>
export const Event = {
Edited: BusEvent.define(
"file.edited",
z.object({
file: z.string(),
}),
),
}
export function init() {
return runPromiseInstance(Service.use((svc) => svc.init()))
}
export async function status() {
return runPromiseInstance(Service.use((svc) => svc.status()))
}
export async function read(file: string): Promise<Content> {
return runPromiseInstance(Service.use((svc) => svc.read(file)))
}
export async function list(dir?: string) {
return runPromiseInstance(Service.use((svc) => svc.list(dir)))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromiseInstance(Service.use((svc) => svc.search(input)))
}
const log = Log.create({ service: "file" }) const log = Log.create({ service: "file" })
const binaryExtensions = new Set([ const binary = new Set([
"exe", "exe",
"dll", "dll",
"pdb", "pdb",
@@ -120,7 +207,7 @@ const binaryExtensions = new Set([
"fish", "fish",
]) ])
const imageExtensions = new Set([ const image = new Set([
"png", "png",
"jpg", "jpg",
"jpeg", "jpeg",
@@ -148,7 +235,7 @@ const imageExtensions = new Set([
"x3f", "x3f",
]) ])
const textExtensions = new Set([ const text = new Set([
"ts", "ts",
"tsx", "tsx",
"mts", "mts",
@@ -192,7 +279,7 @@ const textExtensions = new Set([
"env", "env",
]) ])
const textNames = new Set([ const textName = new Set([
"dockerfile", "dockerfile",
"makefile", "makefile",
".gitignore", ".gitignore",
@@ -204,24 +291,7 @@ const textNames = new Set([
".eslintrc", ".eslintrc",
]) ])
function isImageByExtension(filepath: string): boolean { const mime: Record<string, string> = {
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", png: "image/png",
jpg: "image/jpeg", jpg: "image/jpeg",
jpeg: "image/jpeg", jpeg: "image/jpeg",
@@ -239,125 +309,45 @@ function getImageMimeType(filepath: string): string {
heic: "image/heic", heic: "image/heic",
heif: "image/heif", heif: "image/heif",
} }
return mimeTypes[ext] || "image/" + ext
}
function isBinaryByExtension(filepath: string): boolean { type Entry = { files: string[]; dirs: string[] }
const ext = path.extname(filepath).toLowerCase().slice(1)
return binaryExtensions.has(ext)
}
function isImage(mimeType: string): boolean { const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
return mimeType.startsWith("image/") 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): boolean { function shouldEncode(mimeType: string) {
const type = mimeType.toLowerCase() const type = mimeType.toLowerCase()
log.info("shouldEncode", { type }) log.info("shouldEncode", { type })
if (!type) return false if (!type) return false
if (type.startsWith("text/")) return false if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false if (type.includes("charset=")) return false
const top = type.split("/", 2)[0]
const parts = type.split("/", 2) return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
const top = parts[0]
const tops = ["image", "audio", "video", "font", "model", "multipart"]
if (tops.includes(top)) return true
return false
} }
export namespace File { const hidden = (item: string) => {
export const Info = z const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
.object({ return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
path: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.meta({
ref: "File",
})
export type Info = z.infer<typeof Info>
export const Node = z
.object({
name: z.string(),
path: z.string(),
absolute: z.string(),
type: z.enum(["file", "directory"]),
ignored: z.boolean(),
})
.meta({
ref: "FileNode",
})
export type Node = z.infer<typeof Node>
export const Content = z
.object({
type: z.enum(["text", "binary"]),
content: z.string(),
diff: z.string().optional(),
patch: z
.object({
oldFileName: z.string(),
newFileName: z.string(),
oldHeader: z.string().optional(),
newHeader: z.string().optional(),
hunks: z.array(
z.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
}),
),
index: z.string().optional(),
})
.optional(),
encoding: z.literal("base64").optional(),
mimeType: z.string().optional(),
})
.meta({
ref: "FileContent",
})
export type Content = z.infer<typeof Content>
export const Event = {
Edited: BusEvent.define(
"file.edited",
z.object({
file: z.string(),
}),
),
} }
export function init() { const sortHiddenLast = (items: string[], prefer: boolean) => {
return runPromiseInstance(FileService.use((s) => s.init())) 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 async function status() { export interface Interface {
return runPromiseInstance(FileService.use((s) => s.status()))
}
export async function read(file: string): Promise<Content> {
return runPromiseInstance(FileService.use((s) => s.read(file)))
}
export async function list(dir?: string) {
return runPromiseInstance(FileService.use((s) => s.list(dir)))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromiseInstance(FileService.use((s) => s.search(input)))
}
}
export namespace FileService {
export interface Service {
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 })
}), }),
) )
} }
+53 -58
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"
export namespace FileTime {
const log = Log.create({ service: "file.time" }) const log = Log.create({ service: "file.time" })
export namespace FileTimeService { export type Stamp = {
export interface Service {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
}
}
type Stamp = {
readonly read: Date readonly read: Date
readonly mtime: number | undefined readonly mtime: number | undefined
readonly ctime: number | undefined readonly ctime: number | undefined
readonly size: number | undefined readonly size: number | undefined
} }
function stamp(file: string): Stamp { const stamp = Effect.fnUntraced(function* (file: string) {
const stat = Filesystem.stat(file) const stat = Filesystem.stat(file)
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
return { return {
read: new Date(), read: yield* DateTime.nowAsDate,
mtime: stat?.mtime?.getTime(), mtime: stat?.mtime?.getTime(),
ctime: stat?.ctime?.getTime(), ctime: stat?.ctime?.getTime(),
size, 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
} }
function session(reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) { export interface Interface {
let value = reads.get(sessionID) readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
if (!value) { readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
value = new Map<string, Stamp>() readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
reads.set(sessionID, value) readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
}
return value
} }
export class FileTimeService extends ServiceMap.Service<FileTimeService, FileTimeService.Service>()( export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
"@opencode/FileTime",
) { export const layer = Layer.effect(
static readonly layer = Layer.effect( Service,
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, stamp(file)) session(reads, sessionID).set(file, yield* stamp(file))
}), })
get: Effect.fn("FileTimeService.get")(function* (sessionID: SessionID, file: string) { const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
return reads.get(sessionID)?.get(file)?.read return reads.get(sessionID)?.get(file)?.read
}), })
assert: Effect.fn("FileTimeService.assert")(function* (sessionID: SessionID, filepath: string) { const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return if (disableCheck) return
const time = reads.get(sessionID)?.get(filepath) 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`) 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) { 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( 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.`, `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 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)))
} }
} }
+27 -41
View File
@@ -1,29 +1,29 @@
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
export namespace FileWatcher {
const log = Log.create({ service: "file.watcher" }) const log = Log.create({ service: "file.watcher" })
const SUBSCRIBE_TIMEOUT_MS = 10_000
const event = { export const Event = {
Updated: BusEvent.define( Updated: BusEvent.define(
"file.watcher.updated", "file.watcher.updated",
z.object({ z.object({
@@ -51,39 +51,26 @@ function getBackend() {
if (process.platform === "linux") return "inotify" if (process.platform === "linux") return "inotify"
} }
export namespace FileWatcher {
export const Event = event
/** Whether the native @parcel/watcher binding is available on this platform. */
export const hasNativeBinding = () => !!watcher() export const hasNativeBinding = () => !!watcher()
}
const init = Effect.fn("FileWatcherService.init")(function* () {}) export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
export namespace FileWatcherService { export const layer = Layer.effect(
export interface Service { Service,
readonly init: () => Effect.Effect<void>
}
}
export class FileWatcherService extends ServiceMap.Service<FileWatcherService, FileWatcherService.Service>()(
"@opencode/FileWatcher",
) {
static readonly layer = Layer.effect(
FileWatcherService,
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,6 +11,7 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import z from "zod" import z from "zod"
import { PermissionID } from "./schema" import { PermissionID } from "./schema"
export namespace PermissionEffect {
const log = Log.create({ service: "permission" }) const log = Log.create({ service: "permission" })
export const Action = z.enum(["allow", "deny", "ask"]).meta({ export const Action = z.enum(["allow", "deny", "ask"]).meta({
@@ -96,12 +97,7 @@ export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("Permiss
} }
} }
export type PermissionError = DeniedError | RejectedError | CorrectedError export type Error = DeniedError | RejectedError | CorrectedError
interface PendingEntry {
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,
@@ -113,19 +109,30 @@ export const ReplyInput = z.object({
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[]>
} }
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
} }
export class PermissionService extends ServiceMap.Service<PermissionService, PermissionService.Api>()( export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
"@opencode/PermissionNext", const rules = rulesets.flat()
) { log.info("evaluate", { permission, pattern, ruleset: rules })
static readonly layer = Layer.effect( const match = rules.findLast(
PermissionService, (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)
} }
}) })
} }
+96 -74
View File
@@ -1,23 +1,23 @@
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", {
@@ -28,10 +28,15 @@ function emit(directory: string) {
directory, directory,
}, },
}, },
}) });
} }
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) { function boot(input: {
directory: string;
init?: () => Promise<any>;
project?: Project.Info;
worktree?: string;
}) {
return iife(async () => { return iife(async () => {
const ctx = const ctx =
input.project && input.worktree input.project && input.worktree
@@ -40,54 +45,63 @@ function boot(input: { directory: string; init?: () => Promise<any>; project?: P
worktree: input.worktree, worktree: input.worktree,
project: input.project, project: input.project,
} }
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({ : await Project.fromDirectory(input.directory).then(
({ project, sandbox }) => ({
directory: input.directory, directory: input.directory,
worktree: sandbox, worktree: sandbox,
project, project,
})) }),
);
await context.provide(ctx, async () => { await context.provide(ctx, async () => {
await input.init?.() await input.init?.();
}) });
return ctx 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>;
fn: () => R;
}): Promise<R> {
const directory = Filesystem.resolve(input.directory);
let existing = cache.get(directory);
if (!existing) { if (!existing) {
Log.Default.info("creating instance", { directory }) Log.Default.info("creating instance", { directory });
existing = track( existing = track(
directory, directory,
boot({ boot({
directory, directory,
init: input.init, init: input.init,
}), }),
) );
} }
const ctx = await existing const ctx = await existing;
return context.provide(ctx, async () => { return context.provide(ctx, async () => {
return input.fn() return input.fn();
}) });
},
get current() {
return context.use();
}, },
get directory() { get directory() {
return context.use().directory return context.use().directory;
}, },
get worktree() { get worktree() {
return context.use().worktree return context.use().worktree;
}, },
get project() { get project() {
return context.use().project return context.use().project;
}, },
/** /**
* Check if a path is within the project boundary. * Check if a path is within the project boundary.
@@ -95,11 +109,11 @@ export const Instance = {
* Paths within the worktree but outside the working directory should not trigger external_directory permission. * Paths within the worktree but outside the working directory should not trigger external_directory permission.
*/ */
containsPath(filepath: string) { containsPath(filepath: string) {
if (Filesystem.contains(Instance.directory, filepath)) return true if (Filesystem.contains(Instance.directory, filepath)) return true;
// Non-git projects set worktree to "/" which would match ANY absolute path. // Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions. // Skip worktree check in this case to preserve external_directory permissions.
if (Instance.worktree === "/") return false if (Instance.worktree === "/") return false;
return Filesystem.contains(Instance.worktree, filepath) return Filesystem.contains(Instance.worktree, filepath);
}, },
/** /**
* Captures the current instance ALS context and returns a wrapper that * Captures the current instance ALS context and returns a wrapper that
@@ -107,57 +121,65 @@ export const Instance = {
* instance async context (native addons, event emitters, timers, etc.). * instance async context (native addons, event emitters, timers, etc.).
*/ */
bind<F extends (...args: any[]) => any>(fn: F): F { bind<F extends (...args: any[]) => any>(fn: F): F {
const ctx = context.use() const ctx = context.use();
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F;
}, },
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S { state<S>(
return State.create(() => Instance.directory, init, dispose) init: () => S,
dispose?: (state: Awaited<S>) => Promise<void>,
): () => S {
return State.create(() => Instance.directory, init, dispose);
}, },
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) { async reload(input: {
const directory = Filesystem.resolve(input.directory) directory: string;
Log.Default.info("reloading instance", { directory }) init?: () => Promise<any>;
await Promise.all([State.dispose(directory), disposeInstance(directory)]) project?: Project.Info;
cache.delete(directory) worktree?: string;
const next = track(directory, boot({ ...input, directory })) }) {
emit(directory) const directory = Filesystem.resolve(input.directory);
return await next 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() { async dispose() {
const directory = Instance.directory const directory = Instance.directory;
Log.Default.info("disposing instance", { directory }) Log.Default.info("disposing instance", { directory });
await Promise.all([State.dispose(directory), disposeInstance(directory)]) await Promise.all([State.dispose(directory), disposeInstance(directory)]);
cache.delete(directory) cache.delete(directory);
emit(directory) emit(directory);
}, },
async disposeAll() { async disposeAll() {
if (disposal.all) return disposal.all 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,11 +1,12 @@
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 namespace ProviderAuthEffect {
export const Method = z export const Method = z
.object({ .object({
type: z.union([z.literal("oauth"), z.literal("api")]), type: z.union([z.literal("oauth"), z.literal("api")]),
@@ -43,31 +44,24 @@ export const OauthCodeMissing = NamedError.create(
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 {
+18 -17
View File
@@ -1,9 +1,13 @@
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"
export namespace Discovery {
const skillConcurrency = 4
const fileConcurrency = 8
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({ class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
name: Schema.String, name: Schema.String,
@@ -14,20 +18,15 @@ class Index extends Schema.Class<Index>("Index")({
skills: Schema.Array(IndexSkill), skills: Schema.Array(IndexSkill),
}) {} }) {}
const skillConcurrency = 4 export interface Interface {
const fileConcurrency = 8
export namespace DiscoveryService {
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",
) { export const layer: Layer.Layer<Service, never, FileSystem.FileSystem | Path.Path | HttpClient.HttpClient> =
static readonly layer = Layer.effect( Layer.effect(
DiscoveryService, Service,
Effect.gen(function* () { Effect.gen(function* () {
const log = Log.create({ service: "skill-discovery" }) const log = Log.create({ service: "skill-discovery" })
const fs = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
@@ -35,7 +34,7 @@ export class DiscoveryService extends ServiceMap.Service<DiscoveryService, Disco
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const cache = path.join(Global.Path.cache, "skills") const cache = path.join(Global.Path.cache, "skills")
const download = Effect.fn("DiscoveryService.download")(function* (url: string, dest: string) { const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
return yield* HttpClientRequest.get(url).pipe( return yield* HttpClientRequest.get(url).pipe(
@@ -56,7 +55,7 @@ export class DiscoveryService extends ServiceMap.Service<DiscoveryService, Disco
) )
}) })
const pull: DiscoveryService.Service["pull"] = Effect.fn("DiscoveryService.pull")(function* (url: string) { const pull = Effect.fn("Discovery.pull")(function* (url: string) {
const base = url.endsWith("/") ? url : `${url}/` const base = url.endsWith("/") ? url : `${url}/`
const index = new URL("index.json", base).href const index = new URL("index.json", base).href
const host = base.slice(0, -1) const host = base.slice(0, -1)
@@ -94,7 +93,9 @@ export class DiscoveryService extends ServiceMap.Service<DiscoveryService, Disco
yield* Effect.forEach( yield* Effect.forEach(
skill.files, skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)), (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
{ concurrency: fileConcurrency }, {
concurrency: fileConcurrency,
},
) )
const md = path.join(root, "SKILL.md") const md = path.join(root, "SKILL.md")
@@ -106,11 +107,11 @@ export class DiscoveryService extends ServiceMap.Service<DiscoveryService, Disco
return dirs.filter((dir): dir is string => dir !== null) return dirs.filter((dir): dir is string => dir !== null)
}) })
return DiscoveryService.of({ pull }) return Service.of({ pull })
}), }),
) )
static readonly defaultLayer = DiscoveryService.layer.pipe( 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),
+146 -158
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"
import { Global } from "@/global"
import { PermissionNext } from "@/permission"
import { Filesystem } from "@/util/filesystem"
import { Config } from "../config/config"
import { ConfigMarkdown } from "../config/markdown"
import { Glob } from "../util/glob"
import { Log } from "../util/log"
import { Discovery } from "./discovery"
export namespace Skill {
const log = Log.create({ service: "skill" }) const log = Log.create({ service: "skill" })
// External skill directories to search for (project-level and global)
// These follow the directory layout used by Claude Code and other agents.
const EXTERNAL_DIRS = [".claude", ".agents"] const EXTERNAL_DIRS = [".claude", ".agents"]
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
const SKILL_PATTERN = "**/SKILL.md" const SKILL_PATTERN = "**/SKILL.md"
export namespace Skill {
export const Info = z.object({ export const Info = z.object({
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
@@ -55,64 +51,24 @@ export namespace Skill {
}), }),
) )
export async function get(name: string) { type State = {
return runPromiseInstance(SkillService.use((s) => s.get(name))) skills: Record<string, Info>
dirs: Set<string>
task?: Promise<void>
} }
export async function all() { type Cache = State & {
return runPromiseInstance(SkillService.use((s) => s.all())) ensure: () => Promise<void>
} }
export async function dirs() { export interface Interface {
return runPromiseInstance(SkillService.use((s) => s.dirs())) readonly get: (name: string) => Effect.Effect<Info | undefined>
} readonly all: () => Effect.Effect<Info[]>
export async function available(agent?: Agent.Info) {
return runPromiseInstance(SkillService.use((s) => s.available(agent)))
}
export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) {
return "No skills are currently available."
}
if (opts.verbose) {
return [
"<available_skills>",
...list.flatMap((skill) => [
` <skill>`,
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
` </skill>`,
]),
"</available_skills>",
].join("\n")
}
return ["## Available Skills", ...list.flatMap((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 dirs: () => Effect.Effect<string[]>
readonly available: (agent?: Agent.Info) => Effect.Effect<Skill.Info[]> readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
}
} }
export class SkillService extends ServiceMap.Service<SkillService, SkillService.Service>()("@opencode/Skill") { const add = async (state: State, match: string) => {
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 md = await ConfigMarkdown.parse(match).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err) const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message ? err.data.message
@@ -125,21 +81,19 @@ export class SkillService extends ServiceMap.Service<SkillService, SkillService.
if (!md) return if (!md) return
const parsed = Skill.Info.pick({ name: true, description: true }).safeParse(md.data) const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
if (!parsed.success) return if (!parsed.success) return
// Warn on duplicate skill names if (state.skills[parsed.data.name]) {
if (skills[parsed.data.name]) {
log.warn("duplicate skill name", { log.warn("duplicate skill name", {
name: parsed.data.name, name: parsed.data.name,
existing: skills[parsed.data.name].location, existing: state.skills[parsed.data.name].location,
duplicate: match, duplicate: match,
}) })
} }
skillDirs.add(path.dirname(match)) state.dirs.add(path.dirname(match))
state.skills[parsed.data.name] = {
skills[parsed.data.name] = {
name: parsed.data.name, name: parsed.data.name,
description: parsed.data.description, description: parsed.data.description,
location: match, location: match,
@@ -147,30 +101,34 @@ export class SkillService extends ServiceMap.Service<SkillService, SkillService.
} }
} }
const scanExternal = async (root: string, scope: "global" | "project") => { const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => {
return Glob.scan(EXTERNAL_SKILL_PATTERN, { return Glob.scan(pattern, {
cwd: root, cwd: root,
absolute: true, absolute: true,
include: "file", include: "file",
dot: true,
symlink: true, symlink: true,
dot: opts?.dot,
}) })
.then((matches) => Promise.all(matches.map(addSkill))) .then((matches) => Promise.all(matches.map((match) => add(state, match))))
.catch((error) => { .catch((error) => {
log.error(`failed to scan ${scope} skills`, { dir: root, error }) if (!opts?.scope) throw error
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
}) })
} }
function ensureScanned() { // TODO: Migrate to Effect
if (task) return task const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => {
task = (async () => { const state: State = {
// Scan external skill directories (.claude/skills/, .agents/skills/, etc.) skills: {},
// Load global (home) first, then project-level (so project-level overwrites) dirs: new Set<string>(),
}
const load = async () => {
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
for (const dir of EXTERNAL_DIRS) { for (const dir of EXTERNAL_DIRS) {
const root = path.join(Global.Path.home, dir) const root = path.join(Global.Path.home, dir)
if (!(await Filesystem.isDir(root))) continue if (!(await Filesystem.isDir(root))) continue
await scanExternal(root, "global") await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
} }
for await (const root of Filesystem.up({ for await (const root of Filesystem.up({
@@ -178,90 +136,120 @@ export class SkillService extends ServiceMap.Service<SkillService, SkillService.
start: instance.directory, start: instance.directory,
stop: instance.project.worktree, stop: instance.project.worktree,
})) { })) {
await scanExternal(root, "project") await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
} }
} }
// Scan .opencode/skill/ directories
for (const dir of await Config.directories()) { for (const dir of await Config.directories()) {
const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, { await scan(state, dir, 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 cfg = await Config.get()
const config = await Config.get() for (const item of cfg.skills?.paths ?? []) {
for (const skillPath of config.skills?.paths ?? []) { const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
const expanded = skillPath.startsWith("~/") ? path.join(os.homedir(), skillPath.slice(2)) : skillPath const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
const resolved = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded) if (!(await Filesystem.isDir(dir))) {
if (!(await Filesystem.isDir(resolved))) { log.warn("skill path not found", { path: dir })
log.warn("skill path not found", { path: resolved })
continue continue
} }
const matches = await Glob.scan(SKILL_PATTERN, {
cwd: resolved, await scan(state, dir, SKILL_PATTERN)
absolute: true, }
include: "file",
symlink: true, for (const url of cfg.skills?.urls ?? []) {
}) for (const dir of await Effect.runPromise(discovery.pull(url))) {
for (const match of matches) { state.dirs.add(dir)
await addSkill(match) await scan(state, dir, SKILL_PATTERN)
} }
} }
// Download and load skills from URLs log.info("init", { count: Object.keys(state.skills).length })
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 }) const ensure = () => {
})().catch((err) => { if (state.task) return state.task
task = undefined state.task = load().catch((err) => {
state.task = undefined
throw err throw err
}) })
return task return state.task
} }
return SkillService.of({ return { ...state, ensure }
get: Effect.fn("SkillService.get")(function* (name: string) { }
yield* Effect.promise(() => ensureScanned())
return skills[name] export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
}),
all: Effect.fn("SkillService.all")(function* () { export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = Layer.effect(
yield* Effect.promise(() => ensureScanned()) Service,
return Object.values(skills) Effect.gen(function* () {
}), const instance = yield* InstanceContext
dirs: Effect.fn("SkillService.dirs")(function* () { const discovery = yield* Discovery.Service
yield* Effect.promise(() => ensureScanned()) const state = create(instance, discovery)
return Array.from(skillDirs)
}), const get = Effect.fn("Skill.get")(function* (name: string) {
available: Effect.fn("SkillService.available")(function* (agent?: Agent.Info) { yield* Effect.promise(() => state.ensure())
yield* Effect.promise(() => ensureScanned()) return state.skills[name]
const list = Object.values(skills) })
if (!agent) return list
return list.filter( const all = Effect.fn("Skill.all")(function* () {
(skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny", yield* Effect.promise(() => state.ensure())
) return Object.values(state.skills)
}), })
})
}), const dirs = Effect.fn("Skill.dirs")(function* () {
).pipe(Layer.provide(DiscoveryService.defaultLayer)) 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) {
return runPromiseInstance(Service.use((skill) => skill.get(name)))
}
export async function all() {
return runPromiseInstance(Service.use((skill) => skill.all()))
}
export async function dirs() {
return runPromiseInstance(Service.use((skill) => skill.dirs()))
}
export async function available(agent?: Agent.Info) {
return runPromiseInstance(Service.use((skill) => skill.available(agent)))
}
export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) return "No skills are currently available."
if (opts.verbose) {
return [
"<available_skills>",
...list.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
"</available_skills>",
].join("\n")
}
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
}
} }
+300 -367
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() export async function cleanup() {
const result: FileDiff[] = [] return runPromiseInstance(Service.use((svc) => svc.cleanup()))
}
export async function track() {
return runPromiseInstance(Service.use((svc) => svc.track()))
}
export async function patch(hash: string) {
return runPromiseInstance(Service.use((svc) => svc.patch(hash)))
}
export async function restore(snapshot: string) {
return runPromiseInstance(Service.use((svc) => svc.restore(snapshot)))
}
export async function revert(patches: Patch[]) {
return runPromiseInstance(Service.use((svc) => svc.revert(patches)))
}
export async function diff(hash: string) {
return runPromiseInstance(Service.use((svc) => svc.diff(hash)))
}
export async function diffFull(from: string, to: string) {
return runPromiseInstance(Service.use((svc) => svc.diffFull(from, to)))
}
const log = Log.create({ service: "snapshot" })
const prune = "7.days"
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
const cfg = ["-c", "core.autocrlf=false", ...core]
const quote = [...cfg, "-c", "core.quotepath=false"]
interface GitResult {
readonly code: ChildProcessSpawner.ExitCode
readonly text: string
readonly stderr: string
}
export interface Interface {
readonly cleanup: () => Effect.Effect<void>
readonly track: () => Effect.Effect<string | undefined>
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
readonly restore: (snapshot: string) => Effect.Effect<void>
readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect<void>
readonly diff: (hash: string) => Effect.Effect<string>
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
export const layer: Layer.Layer<
Service,
never,
InstanceContext | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const ctx = yield* InstanceContext
const fs = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const directory = ctx.directory
const worktree = ctx.worktree
const project = ctx.project
const gitdir = path.join(Global.Path.data, "snapshot", project.id)
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
})
const excludes = Effect.fnUntraced(function* () {
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
cwd: worktree,
})
const file = result.text.trim()
if (!file) return
if (!(yield* exists(file))) return
return file
})
const sync = Effect.fnUntraced(function* () {
const file = yield* excludes()
const target = path.join(gitdir, "info", "exclude")
yield* mkdir(path.join(gitdir, "info"))
if (!file) {
yield* write(target, "")
return
}
yield* write(target, yield* read(file))
})
const add = Effect.fnUntraced(function* () {
yield* sync()
yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
})
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 })
})
const track = Effect.fn("Snapshot.track")(function* () {
if (!(yield* enabled())) return
const existed = yield* exists(gitdir)
yield* mkdir(gitdir)
if (!existed) {
yield* git(["init"], {
env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
})
yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"])
yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"])
yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"])
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 status = new Map<string, "added" | "deleted" | "modified">()
const statuses = await Process.text( const statuses = yield* git(
[ [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
"git", { cwd: directory },
"-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")) { for (const line of statuses.text.trim().split("\n")) {
if (!line) continue if (!line) continue
const [code, file] = line.split("\t") const [code, file] = line.split("\t")
if (!code || !file) continue if (!code || !file) continue
const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified" status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
status.set(file, kind)
} }
for (const line of await Process.lines( const numstat = yield* git(
[ [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]),
],
{ {
cwd: Instance.directory, cwd: directory,
nothrow: true,
}, },
)) { )
for (const line of numstat.text.trim().split("\n")) {
if (!line) continue if (!line) continue
const [additions, deletions, file] = line.split("\t") const [adds, dels, file] = line.split("\t")
const isBinaryFile = additions === "-" && deletions === "-" if (!file) continue
const before = isBinaryFile const binary = adds === "-" && dels === "-"
? "" const [before, after] = binary
: await Process.text( ? ["", ""]
: yield* Effect.all(
[ [
"git", git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
"-c", git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${from}:${file}`]),
], ],
{ nothrow: true }, { concurrency: 2 },
).then((x) => x.text) )
const after = isBinaryFile const additions = binary ? 0 : parseInt(adds)
? "" const deletions = binary ? 0 : parseInt(dels)
: await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${to}:${file}`]),
],
{ nothrow: true },
).then((x) => x.text)
const added = isBinaryFile ? 0 : parseInt(additions)
const deleted = isBinaryFile ? 0 : parseInt(deletions)
result.push({ result.push({
file, file,
before, before,
after, after,
additions: Number.isFinite(added) ? added : 0, additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deleted) ? deleted : 0, deletions: Number.isFinite(deletions) ? deletions : 0,
status: status.get(file) ?? "modified", status: status.get(file) ?? "modified",
}) })
} }
return result return result
} })
function gitdir() { yield* cleanup().pipe(
const project = Instance.project Effect.catchCause((cause) => {
return path.join(Global.Path.data, "snapshot", project.id) 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,
)
async function add(git: string) { return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
await syncExclude(git) }),
await Process.run( )
[
"git", export const defaultLayer = layer.pipe(
"-c", Layer.provide(NodeChildProcessSpawner.layer),
"core.autocrlf=false", Layer.provide(NodeFileSystem.layer),
"-c", Layer.provide(NodePath.layer),
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["add", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
) )
} }
async function syncExclude(git: string) {
const file = await excludes()
const target = path.join(git, "info", "exclude")
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)
}
async function excludes() {
const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
cwd: Instance.worktree,
nothrow: true,
}).then((x) => x.text)
if (!file.trim()) return
const exists = await fs
.stat(file.trim())
.then(() => true)
.catch(() => false)
if (!exists) return
return file.trim()
}
}
+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)))),
})
+18 -12
View File
@@ -1,6 +1,6 @@
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(
@@ -8,7 +8,7 @@ export const watcherConfigLayer = ConfigProvider.layer(
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
@@ -28,20 +28,26 @@ export function withServices<S>(
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,
project: Instance.project,
}),
);
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(
Layer.provide(ctx),
) as any;
if (options?.provide) { if (options?.provide) {
for (const l of options.provide) { for (const l of options.provide) {
resolved = resolved.pipe(Layer.provide(l)) as any resolved = resolved.pipe(Layer.provide(l)) as any;
} }
} }
const rt = ManagedRuntime.make(resolved) const rt = ManagedRuntime.make(resolved);
try { try {
await body(rt) await body(rt);
} finally { } finally {
await rt.dispose() 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)
})
}) })
}) })