diff options
Diffstat (limited to 'packages/core/src')
| -rw-r--r-- | packages/core/src/effect/logger.ts | 73 | ||||
| -rw-r--r-- | packages/core/src/effect/memo-map.ts | 3 | ||||
| -rw-r--r-- | packages/core/src/effect/observability.ts | 107 | ||||
| -rw-r--r-- | packages/core/src/effect/runtime.ts | 21 | ||||
| -rw-r--r-- | packages/core/src/flag/flag.ts | 107 | ||||
| -rw-r--r-- | packages/core/src/global.ts | 41 | ||||
| -rw-r--r-- | packages/core/src/installation/version.ts | 8 | ||||
| -rw-r--r-- | packages/core/src/util/log.ts | 185 | ||||
| -rw-r--r-- | packages/core/src/util/opencode-process.ts | 24 |
9 files changed, 553 insertions, 16 deletions
diff --git a/packages/core/src/effect/logger.ts b/packages/core/src/effect/logger.ts new file mode 100644 index 000000000..69f9631e0 --- /dev/null +++ b/packages/core/src/effect/logger.ts @@ -0,0 +1,73 @@ +import { Cause, Effect, Logger, References } from "effect" +import * as Log from "../util/log" + +type Fields = Record<string, unknown> + +const normalizeKey = (key: string) => (key === "sessionID" ? "session.id" : key) + +export interface Handle { + readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void> + readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void> + readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void> + readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void> + readonly with: (extra: Fields) => Handle +} + +const clean = (input?: Fields): Fields => + Object.fromEntries( + Object.entries(input ?? {}) + .filter((entry) => entry[1] !== undefined && entry[1] !== null) + .map(([key, value]) => [normalizeKey(key), value]), + ) + +const text = (input: unknown): string => { + // oxlint-disable-next-line no-base-to-string + if (Array.isArray(input)) return input.map((item) => String(item)).join(" ") + // oxlint-disable-next-line no-base-to-string + return input === undefined ? "" : String(input) +} + +const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => { + const ann = clean({ ...base, ...extra }) + const fx = run(msg) + return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx +} + +export const logger = Logger.make((opts) => { + const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations)) + const now = opts.date.getTime() + for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) { + extra[`logSpan.${key}`] = `${now - start}ms` + } + if (opts.cause.reasons.length > 0) { + extra.cause = Cause.pretty(opts.cause) + } + + const svc = typeof extra.service === "string" ? extra.service : undefined + if (svc) delete extra.service + const log = svc ? Log.create({ service: svc }) : Log.Default + const msg = text(opts.message) + + switch (opts.logLevel) { + case "Trace": + case "Debug": + return log.debug(msg, extra) + case "Warn": + return log.warn(msg, extra) + case "Error": + case "Fatal": + return log.error(msg, extra) + default: + return log.info(msg, extra) + } +}) + +export const layer = Logger.layer([logger], { mergeWithExisting: false }) + +export const create = (base: Fields = {}): Handle => ({ + debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra), + info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra), + warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra), + error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra), + with: (extra) => create({ ...base, ...extra }), +}) diff --git a/packages/core/src/effect/memo-map.ts b/packages/core/src/effect/memo-map.ts new file mode 100644 index 000000000..c797dbf42 --- /dev/null +++ b/packages/core/src/effect/memo-map.ts @@ -0,0 +1,3 @@ +import { Layer } from "effect" + +export const memoMap = Layer.makeMemoMapUnsafe() diff --git a/packages/core/src/effect/observability.ts b/packages/core/src/effect/observability.ts new file mode 100644 index 000000000..0203079ab --- /dev/null +++ b/packages/core/src/effect/observability.ts @@ -0,0 +1,107 @@ +import { Effect, Layer, Logger } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability" +import * as EffectLogger from "./logger" +import { Flag } from "../flag/flag" +import { InstallationChannel, InstallationVersion } from "../installation/version" +import { ensureProcessMetadata } from "../util/opencode-process" + +const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT +export const enabled = !!base +const processID = crypto.randomUUID() + +const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS + ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce( + (acc, x) => { + const [key, ...value] = x.split("=") + acc[key] = value.join("=") + return acc + }, + {} as Record<string, string>, + ) + : undefined + +export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } { + const processMetadata = ensureProcessMetadata("main") + const attributes: Record<string, string> = (() => { + const value = process.env.OTEL_RESOURCE_ATTRIBUTES + if (!value) return {} + try { + return Object.fromEntries( + value.split(",").map((entry) => { + const index = entry.indexOf("=") + if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry") + return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))] + }), + ) + } catch { + return {} + } + })() + + return { + serviceName: "opencode", + serviceVersion: InstallationVersion, + attributes: { + ...attributes, + "deployment.environment.name": InstallationChannel, + "opencode.client": Flag.OPENCODE_CLIENT, + "opencode.process_role": processMetadata.processRole, + "opencode.run_id": processMetadata.runID, + "service.instance.id": processID, + }, + } +} + +function logs() { + return Logger.layer( + [ + EffectLogger.logger, + OtlpLogger.make({ + url: `${base}/v1/logs`, + resource: resource(), + headers, + }), + ], + { mergeWithExisting: false }, + ).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer)) +} + +const traces = async () => { + const NodeSdk = await import("@effect/opentelemetry/NodeSdk") + const OTLP = await import("@opentelemetry/exporter-trace-otlp-http") + const SdkBase = await import("@opentelemetry/sdk-trace-base") + + // @effect/opentelemetry creates a NodeTracerProvider but never calls + // register(), so the global @opentelemetry/api context manager stays + // as the no-op default. Non-Effect code (like the AI SDK) that calls + // tracer.startActiveSpan() relies on context.active() to find the + // parent span - without a real context manager every span starts a + // new trace. Registering AsyncLocalStorageContextManager fixes this. + const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks") + const { context } = await import("@opentelemetry/api") + const mgr = new AsyncLocalStorageContextManager() + mgr.enable() + context.setGlobalContextManager(mgr) + + return NodeSdk.layer(() => ({ + resource: resource(), + spanProcessor: new SdkBase.BatchSpanProcessor( + new OTLP.OTLPTraceExporter({ + url: `${base}/v1/traces`, + headers, + }), + ), + })) +} + +export const layer = !base + ? EffectLogger.layer + : Layer.unwrap( + Effect.gen(function* () { + const trace = yield* Effect.promise(traces) + return Layer.mergeAll(trace, logs()) + }), + ) + +export const Observability = { enabled, layer } diff --git a/packages/core/src/effect/runtime.ts b/packages/core/src/effect/runtime.ts new file mode 100644 index 000000000..e4f682709 --- /dev/null +++ b/packages/core/src/effect/runtime.ts @@ -0,0 +1,21 @@ +import { Layer, type Context, ManagedRuntime, type Effect } from "effect" +import { memoMap } from "./memo-map" +import { Observability } from "./observability" + +export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) { + let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined + const getRuntime = () => + (rt ??= ManagedRuntime.make(Layer.provideMerge(layer, Observability.layer) as Layer.Layer<I, E>, { + memoMap, + })) + + return { + runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)), + runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => + getRuntime().runPromiseExit(service.use(fn), options), + runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => + getRuntime().runPromise(service.use(fn), options), + runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)), + runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)), + } +} diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts new file mode 100644 index 000000000..72c8931f5 --- /dev/null +++ b/packages/core/src/flag/flag.ts @@ -0,0 +1,107 @@ +import { Config } from "effect" + +function truthy(key: string) { + const value = process.env[key]?.toLowerCase() + return value === "true" || value === "1" +} + +function falsy(key: string) { + const value = process.env[key]?.toLowerCase() + return value === "false" || value === "0" +} + +function number(key: string) { + const value = process.env[key] + if (!value) return undefined + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined +} + +const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") +const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE") +const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS = + OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS") +const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"] + +export const Flag = { + OTEL_EXPORTER_OTLP_ENDPOINT: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"], + OTEL_EXPORTER_OTLP_HEADERS: process.env["OTEL_EXPORTER_OTLP_HEADERS"], + + OPENCODE_AUTO_SHARE: truthy("OPENCODE_AUTO_SHARE"), + OPENCODE_AUTO_HEAP_SNAPSHOT: truthy("OPENCODE_AUTO_HEAP_SNAPSHOT"), + OPENCODE_GIT_BASH_PATH: process.env["OPENCODE_GIT_BASH_PATH"], + OPENCODE_CONFIG: process.env["OPENCODE_CONFIG"], + OPENCODE_CONFIG_CONTENT: process.env["OPENCODE_CONFIG_CONTENT"], + OPENCODE_DISABLE_AUTOUPDATE: truthy("OPENCODE_DISABLE_AUTOUPDATE"), + OPENCODE_ALWAYS_NOTIFY_UPDATE: truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE"), + OPENCODE_DISABLE_PRUNE: truthy("OPENCODE_DISABLE_PRUNE"), + OPENCODE_DISABLE_TERMINAL_TITLE: truthy("OPENCODE_DISABLE_TERMINAL_TITLE"), + OPENCODE_SHOW_TTFD: truthy("OPENCODE_SHOW_TTFD"), + OPENCODE_PERMISSION: process.env["OPENCODE_PERMISSION"], + OPENCODE_DISABLE_DEFAULT_PLUGINS: truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS"), + OPENCODE_DISABLE_LSP_DOWNLOAD: truthy("OPENCODE_DISABLE_LSP_DOWNLOAD"), + OPENCODE_ENABLE_EXPERIMENTAL_MODELS: truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"), + OPENCODE_DISABLE_AUTOCOMPACT: truthy("OPENCODE_DISABLE_AUTOCOMPACT"), + OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"), + OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"), + OPENCODE_DISABLE_CLAUDE_CODE, + OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"), + OPENCODE_DISABLE_CLAUDE_CODE_SKILLS, + OPENCODE_DISABLE_EXTERNAL_SKILLS: OPENCODE_DISABLE_CLAUDE_CODE_SKILLS || truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"), + OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"], + OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"], + OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"], + OPENCODE_ENABLE_QUESTION_TOOL: truthy("OPENCODE_ENABLE_QUESTION_TOOL"), + + // Experimental + OPENCODE_EXPERIMENTAL, + OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe( + Config.withDefault(false), + ), + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe( + Config.withDefault(false), + ), + OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"), + OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT: + copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"), + OPENCODE_ENABLE_EXA: truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA"), + OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), + OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), + OPENCODE_EXPERIMENTAL_OXFMT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT"), + OPENCODE_EXPERIMENTAL_LSP_TY: truthy("OPENCODE_EXPERIMENTAL_LSP_TY"), + OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"), + OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"), + OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"), + OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], + OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"], + OPENCODE_DISABLE_EMBEDDED_WEB_UI: truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI"), + OPENCODE_DB: process.env["OPENCODE_DB"], + OPENCODE_DISABLE_CHANNEL_DB: truthy("OPENCODE_DISABLE_CHANNEL_DB"), + OPENCODE_SKIP_MIGRATIONS: truthy("OPENCODE_SKIP_MIGRATIONS"), + OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"), + + OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], + OPENCODE_EXPERIMENTAL_HTTPAPI: truthy("OPENCODE_EXPERIMENTAL_HTTPAPI"), + OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), + + // Evaluated at access time (not module load) because tests, the CLI, and + // external tooling set these env vars at runtime. + get OPENCODE_DISABLE_PROJECT_CONFIG() { + return truthy("OPENCODE_DISABLE_PROJECT_CONFIG") + }, + get OPENCODE_TUI_CONFIG() { + return process.env["OPENCODE_TUI_CONFIG"] + }, + get OPENCODE_CONFIG_DIR() { + return process.env["OPENCODE_CONFIG_DIR"] + }, + get OPENCODE_PURE() { + return truthy("OPENCODE_PURE") + }, + get OPENCODE_PLUGIN_META_FILE() { + return process.env["OPENCODE_PLUGIN_META_FILE"] + }, + get OPENCODE_CLIENT() { + return process.env["OPENCODE_CLIENT"] ?? "cli" + }, +} diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 538cc091b..bf605618f 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -3,6 +3,24 @@ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" import os from "os" import { Context, Effect, Layer } from "effect" +const app = "opencode" +const data = path.join(xdgData!, app) +const cache = path.join(xdgCache!, app) +const config = path.join(xdgConfig!, app) +const state = path.join(xdgState!, app) + +export const Path = { + get home() { + return process.env.OPENCODE_TEST_HOME ?? os.homedir() + }, + data, + bin: path.join(cache, "bin"), + log: path.join(data, "log"), + cache, + config, + state, +} + export namespace Global { export class Service extends Context.Service<Service, Interface>()("@opencode/Global") {} @@ -19,23 +37,14 @@ export namespace Global { export const layer = Layer.effect( Service, Effect.gen(function* () { - const app = "opencode" - const home = process.env.OPENCODE_TEST_HOME ?? os.homedir() - const data = path.join(xdgData!, app) - const cache = path.join(xdgCache!, app) - const cfg = path.join(xdgConfig!, app) - const state = path.join(xdgState!, app) - const bin = path.join(cache, "bin") - const log = path.join(data, "log") - return Service.of({ - home, - data, - cache, - config: cfg, - state, - bin, - log, + home: Path.home, + data: Path.data, + cache: Path.cache, + config: Path.config, + state: Path.state, + bin: Path.bin, + log: Path.log, }) }), ) diff --git a/packages/core/src/installation/version.ts b/packages/core/src/installation/version.ts new file mode 100644 index 000000000..25d9cd99a --- /dev/null +++ b/packages/core/src/installation/version.ts @@ -0,0 +1,8 @@ +declare global { + const OPENCODE_VERSION: string + const OPENCODE_CHANNEL: string +} + +export const InstallationVersion = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" +export const InstallationChannel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" +export const InstallationLocal = InstallationChannel === "local" diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts new file mode 100644 index 000000000..a61c15f7a --- /dev/null +++ b/packages/core/src/util/log.ts @@ -0,0 +1,185 @@ +import path from "path" +import fs from "fs/promises" +import { createWriteStream } from "fs" +import * as Global from "../global" +import z from "zod" +import { Glob } from "./glob" + +export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" }) +export type Level = z.infer<typeof Level> + +const levelPriority: Record<Level, number> = { + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3, +} +const keep = 10 + +let level: Level = "INFO" + +function shouldLog(input: Level): boolean { + return levelPriority[input] >= levelPriority[level] +} + +export type Logger = { + debug(message?: any, extra?: Record<string, any>): void + info(message?: any, extra?: Record<string, any>): void + error(message?: any, extra?: Record<string, any>): void + warn(message?: any, extra?: Record<string, any>): void + tag(key: string, value: string): Logger + clone(): Logger + time( + message: string, + extra?: Record<string, any>, + ): { + stop(): void + [Symbol.dispose](): void + } +} + +const loggers = new Map<string, Logger>() + +export const Default = create({ service: "default" }) + +export interface Options { + print: boolean + dev?: boolean + level?: Level +} + +let logpath = "" +export function file() { + return logpath +} +let write = (msg: any) => { + process.stderr.write(msg) + return msg.length +} + +export async function init(options: Options) { + if (options.level) level = options.level + void cleanup(Global.Path.log) + if (options.print) return + logpath = path.join( + Global.Path.log, + options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log", + ) + await fs.truncate(logpath).catch(() => {}) + const stream = createWriteStream(logpath, { flags: "a" }) + write = async (msg: any) => { + return new Promise((resolve, reject) => { + stream.write(msg, (err) => { + if (err) reject(err) + else resolve(msg.length) + }) + }) + } +} + +async function cleanup(dir: string) { + const files = ( + await Glob.scan("????-??-??T??????.log", { + cwd: dir, + absolute: false, + include: "file", + }).catch(() => []) + ) + .filter((file) => path.basename(file) === file) + .sort() + if (files.length <= keep) return + + const doomed = files.slice(0, -keep) + await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {}))) +} + +function formatError(error: Error, depth = 0): string { + const result = error.message + return error.cause instanceof Error && depth < 10 + ? result + " Caused by: " + formatError(error.cause, depth + 1) + : result +} + +let last = Date.now() +export function create(tags?: Record<string, any>) { + tags = tags || {} + + const service = tags["service"] + if (service && typeof service === "string") { + const cached = loggers.get(service) + if (cached) { + return cached + } + } + + function build(message: any, extra?: Record<string, any>) { + const prefix = Object.entries({ + ...tags, + ...extra, + }) + .filter(([_, value]) => value !== undefined && value !== null) + .map(([key, value]) => { + const prefix = `${key}=` + if (value instanceof Error) return prefix + formatError(value) + if (typeof value === "object") return prefix + JSON.stringify(value) + return prefix + value + }) + .join(" ") + const next = new Date() + const diff = next.getTime() - last + last = next.getTime() + return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n" + } + const result: Logger = { + debug(message?: any, extra?: Record<string, any>) { + if (shouldLog("DEBUG")) { + write("DEBUG " + build(message, extra)) + } + }, + info(message?: any, extra?: Record<string, any>) { + if (shouldLog("INFO")) { + write("INFO " + build(message, extra)) + } + }, + error(message?: any, extra?: Record<string, any>) { + if (shouldLog("ERROR")) { + write("ERROR " + build(message, extra)) + } + }, + warn(message?: any, extra?: Record<string, any>) { + if (shouldLog("WARN")) { + write("WARN " + build(message, extra)) + } + }, + tag(key: string, value: string) { + if (tags) tags[key] = value + return result + }, + clone() { + return create({ ...tags }) + }, + time(message: string, extra?: Record<string, any>) { + const now = Date.now() + result.info(message, { status: "started", ...extra }) + function stop() { + result.info(message, { + status: "completed", + duration: Date.now() - now, + ...extra, + }) + } + return { + stop, + [Symbol.dispose]() { + stop() + }, + } + }, + } + + if (service && typeof service === "string") { + loggers.set(service, result) + } + + return result +} diff --git a/packages/core/src/util/opencode-process.ts b/packages/core/src/util/opencode-process.ts new file mode 100644 index 000000000..f59270ad2 --- /dev/null +++ b/packages/core/src/util/opencode-process.ts @@ -0,0 +1,24 @@ +export const OPENCODE_RUN_ID = "OPENCODE_RUN_ID" +export const OPENCODE_PROCESS_ROLE = "OPENCODE_PROCESS_ROLE" + +export function ensureRunID() { + return (process.env[OPENCODE_RUN_ID] ??= crypto.randomUUID()) +} + +export function ensureProcessRole(fallback: "main" | "worker") { + return (process.env[OPENCODE_PROCESS_ROLE] ??= fallback) +} + +export function ensureProcessMetadata(fallback: "main" | "worker") { + return { + runID: ensureRunID(), + processRole: ensureProcessRole(fallback), + } +} + +export function sanitizedProcessEnv(overrides?: Record<string, string>) { + const env = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ) + return overrides ? Object.assign(env, overrides) : env +} |
