@opencode-ai/util 0.0.0-dev-19205 → 0.0.0-dev-19208

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,14 @@
1
1
  import { Effect, FileSystem, Logger } from "effect";
2
+ export declare const LOG_MAX_BYTES: number;
3
+ export declare const LOG_KEEP_BYTES: number;
4
+ export declare const LOG_TRIM_INTERVAL = "1 hour";
5
+ export declare const LOG_TRIM_LOCK_STALE_MS: number;
2
6
  export declare function file(local?: boolean, channel?: string): string;
3
7
  export declare function fileLogger(target?: string, id?: string): Effect.Effect<Logger.Logger<unknown, void>, import("effect/PlatformError").PlatformError, import("effect/Scope").Scope | FileSystem.FileSystem>;
8
+ export declare const trim: (target: string, options?: {
9
+ max?: number;
10
+ keep?: number;
11
+ } | undefined) => Effect.Effect<void, import("effect/PlatformError").PlatformError, FileSystem.FileSystem>;
4
12
  export declare function minimumLogLevel(): "Error" | "Warn" | "Info" | "Debug";
5
13
  export declare function loggers(local?: boolean, channel?: string): (Logger.Logger<unknown, void> | Effect.Effect<Logger.Logger<unknown, void>, import("effect/PlatformError").PlatformError, import("effect/Scope").Scope | FileSystem.FileSystem>)[];
6
14
  export * as Logging from "./logging.js";
@@ -1,7 +1,16 @@
1
- import { Effect, FileSystem, Formatter, Logger } from "effect";
1
+ import { Effect, FileSystem, Formatter, Logger, Option, Schedule, Stream } from "effect";
2
2
  import path from "path";
3
3
  import { Global } from "../global.js";
4
4
  import { runID } from "./shared.js";
5
+ // One log file is shared by every opencode process on the machine and only ever appended to, so it
6
+ // is bounded by compacting in place instead of rotating: once it passes LOG_MAX_BYTES the head is
7
+ // dropped so roughly LOG_KEEP_BYTES remain, rounded forward to the next line boundary.
8
+ export const LOG_MAX_BYTES = 50 * 1024 * 1024;
9
+ export const LOG_KEEP_BYTES = 25 * 1024 * 1024;
10
+ export const LOG_TRIM_INTERVAL = "1 hour";
11
+ // A trim of a 1 GB log takes well under a second, so a lock older than this belongs to a dead process.
12
+ export const LOG_TRIM_LOCK_STALE_MS = 5 * 60 * 1000;
13
+ const LOG_TRIM_CHUNK = 64 * 1024;
5
14
  function formatter(id = runID()) {
6
15
  return Logger.map(Logger.formatStructured, (output) => {
7
16
  const messages = Array.isArray(output.message) ? output.message : [output.message];
@@ -50,7 +59,75 @@ export function fileLogger(target = file(), id = runID()) {
50
59
  return Effect.gen(function* () {
51
60
  const fs = yield* FileSystem.FileSystem;
52
61
  yield* fs.makeDirectory(path.dirname(target), { recursive: true });
53
- return yield* Logger.toFile(formatter(id), target, { flag: "a" });
62
+ const logger = yield* Logger.toFile(formatter(id), target, { flag: "a" });
63
+ yield* trim(target).pipe(Effect.ignore, Effect.repeat(Schedule.spaced(LOG_TRIM_INTERVAL)), Effect.forkScoped({ startImmediately: true }));
64
+ return logger;
65
+ });
66
+ }
67
+ // Compacts in place rather than writing a temp file and renaming over the log. Other processes hold
68
+ // the same file open with O_APPEND, so they keep appending to the compacted file, whereas a rename
69
+ // would strand them on the unlinked inode and lose their output.
70
+ //
71
+ // Appenders are not coordinated with the final truncate, so a batch flushed by another process
72
+ // between the last read and the truncate is lost. That window is a few milliseconds once per trim,
73
+ // which is an accepted trade for not wrapping every log write in a cross-process lock.
74
+ export const trim = Effect.fn("Logging.trim")(function* (target, options = {}) {
75
+ const max = options.max ?? LOG_MAX_BYTES;
76
+ const keep = options.keep ?? LOG_KEEP_BYTES;
77
+ const fs = yield* FileSystem.FileSystem;
78
+ // Every opencode process on the machine runs this against the same file. Two trimmers racing
79
+ // would have one compute its cut from a size the other already shrank, so only one may proceed
80
+ // and the rest skip until the next interval. mkdir is the atomic primitive on every platform.
81
+ // acquireRelease keeps the mkdir uninterruptible, so a scope closing mid-call cannot leave a lock
82
+ // on disk with no finalizer registered to remove it.
83
+ const lock = `${target}.trim`;
84
+ const acquired = yield* Effect.acquireRelease(fs.makeDirectory(lock).pipe(Effect.as(true), Effect.catchIf((error) => error.reason._tag === "AlreadyExists", () => breakStaleLock(fs, lock))), (acquired) => (acquired ? fs.remove(lock, { recursive: true }).pipe(Effect.ignore) : Effect.void));
85
+ if (!acquired)
86
+ return;
87
+ const size = Number((yield* fs.stat(target)).size);
88
+ if (size <= max)
89
+ return;
90
+ const handle = yield* fs.open(target, { flag: "r+" });
91
+ const start = yield* lineStart(handle, size - keep);
92
+ yield* handle.seek(0, "start");
93
+ // Reads run to the current EOF so lines appended since the stat survive; the write cursor always
94
+ // trails the read cursor so the forward copy never overwrites unread bytes.
95
+ const written = yield* fs.stream(target, { offset: start, chunkSize: LOG_TRIM_CHUNK }).pipe(Stream.runFoldEffect(() => 0, (total, chunk) => handle.writeAll(chunk).pipe(Effect.as(total + chunk.length))));
96
+ yield* handle.truncate(written);
97
+ }, Effect.scoped);
98
+ // Removes a lock left by a process that died mid-trim. Still yields this round: the next interval
99
+ // acquires cleanly, and a process that is legitimately trimming right now keeps its lock.
100
+ //
101
+ // Two processes that both observe the same stale lock can race here: one removes it, a third
102
+ // acquires fresh, and the other removes that fresh lock. That needs a crash inside a sub-second trim
103
+ // followed by three processes ticking within the same few milliseconds, and the consequence is the
104
+ // same bounded tail loss documented on `trim`. Not worth a breaker protocol; see EffectFlock if it is.
105
+ function breakStaleLock(fs, lock) {
106
+ return Effect.gen(function* () {
107
+ const info = yield* fs.stat(lock).pipe(Effect.option);
108
+ const modified = Option.flatMap(info, (value) => value.mtime);
109
+ if (Option.isNone(modified))
110
+ return false;
111
+ if (Date.now() - modified.value.getTime() < LOG_TRIM_LOCK_STALE_MS)
112
+ return false;
113
+ yield* fs.remove(lock, { recursive: true }).pipe(Effect.ignore);
114
+ return false;
115
+ });
116
+ }
117
+ // First byte after the first newline at or beyond `from`, or EOF when the tail has no newline.
118
+ function lineStart(handle, from) {
119
+ return Effect.gen(function* () {
120
+ let cursor = from;
121
+ while (true) {
122
+ yield* handle.seek(cursor, "start");
123
+ const chunk = yield* handle.readAlloc(LOG_TRIM_CHUNK);
124
+ if (Option.isNone(chunk))
125
+ return cursor;
126
+ const newline = chunk.value.indexOf(10);
127
+ if (newline !== -1)
128
+ return cursor + newline + 1;
129
+ cursor += chunk.value.length;
130
+ }
54
131
  });
55
132
  }
56
133
  const stderrLogger = Logger.make((options) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@opencode-ai/util",
4
- "version": "0.0.0-dev-19205",
4
+ "version": "0.0.0-dev-19208",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {