@llm4ts/runner 2.21.0 → 2.22.0

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.
package/src/Watch.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readdirSync, existsSync, statSync } from "node:fs"
2
2
  import { join } from "node:path"
3
+ import * as Clock from "effect/Clock"
3
4
  import * as Duration from "effect/Duration"
4
5
  import * as Effect from "effect/Effect"
5
6
  import * as Queue from "effect/Queue"
@@ -22,7 +23,7 @@ import {
22
23
  type TreeState,
23
24
  type TreeView
24
25
  } from "./AgentTree.ts"
25
- import { nodeTreeKeys } from "./AgentTreeSurface.ts"
26
+ import { enterScreen, leaveScreen, makeScreenPainter, nodeTreeKeys } from "./AgentTreeSurface.ts"
26
27
  import { nodeListTraces, TraceDirectoryName } from "./Costs.ts"
27
28
  import { nodePlainFileStore } from "./NodePlainFileStore.ts"
28
29
 
@@ -42,6 +43,8 @@ export interface WatchOptions {
42
43
  readonly replay?: boolean
43
44
  /** Replay speed-up (default 10). */
44
45
  readonly speed?: number
46
+ /** A live lane with no event for this long is marked idle (`LLM4TS_IDLE_AFTER`). */
47
+ readonly idleAfterMs?: number
45
48
  }
46
49
 
47
50
  export interface WatchOutput {
@@ -68,7 +71,13 @@ export class WatchTargetMissing extends Schema.TaggedError<WatchTargetMissing>()
68
71
  { message: Schema.String }
69
72
  ) {}
70
73
 
71
- interface WatchTarget {
74
+ /** Where a run's trace and its epic are read from. */
75
+ export type TraceSources = Pick<WatchDependencies, "files" | "listTraces" | "listEpics">
76
+
77
+ /** A trace to read: given, an epic's latest run, or the newest in the repository. */
78
+ export type TraceChoice = Pick<WatchOptions, "repo" | "trace" | "epic">
79
+
80
+ export interface WatchTarget {
72
81
  readonly tracePath: string
73
82
  readonly epicDir: string | undefined
74
83
  readonly run: EpicRun | undefined
@@ -80,7 +89,7 @@ const epicsDirOf = (repo: string): string => join(repo, TraceDirectoryName, "epi
80
89
  const epicOfTrace = (
81
90
  tracePath: string,
82
91
  repo: string,
83
- dependencies: WatchDependencies
92
+ dependencies: TraceSources
84
93
  ): Effect.Effect<{ readonly dir: string; readonly run: EpicRun } | undefined, FlowError> =>
85
94
  Effect.gen(function* () {
86
95
  const epicsDir = epicsDirOf(repo)
@@ -96,9 +105,9 @@ const epicOfTrace = (
96
105
  return undefined
97
106
  })
98
107
 
99
- const resolveTarget = (
100
- options: WatchOptions,
101
- dependencies: WatchDependencies
108
+ export const resolveTraceTarget = (
109
+ options: TraceChoice,
110
+ dependencies: TraceSources
102
111
  ): Effect.Effect<WatchTarget, FlowError | WatchTargetMissing> =>
103
112
  Effect.gen(function* () {
104
113
  if (options.epic !== undefined) {
@@ -143,12 +152,14 @@ const actionWords = (run: EpicRun | undefined): string | undefined => {
143
152
  /** The tree before any event: the epic's board, when the trace belongs to one. */
144
153
  const startingTree = (
145
154
  target: WatchTarget,
146
- files: PlainFileStoreShape
155
+ files: PlainFileStoreShape,
156
+ idleAfterMs: number | undefined
147
157
  ): Effect.Effect<TreeState, FlowError> =>
148
158
  Effect.gen(function* () {
149
159
  const action = actionWords(target.run)
160
+ const idle = idleAfterMs === undefined ? {} : { idleAfterMs }
150
161
  if (target.epicDir === undefined) {
151
- return emptyTree(action === undefined ? {} : { action })
162
+ return emptyTree({ ...idle, ...(action === undefined ? {} : { action }) })
152
163
  }
153
164
  const boardDir =
154
165
  target.run?.round === undefined
@@ -159,6 +170,7 @@ const startingTree = (
159
170
  return emptyTree({
160
171
  title: board === undefined ? `epic ${epicId}` : board.title.replace(/^Epic:\s*/u, "epic "),
161
172
  stories: (board?.items ?? []).map((item) => ({ id: item.id, status: item.status })),
173
+ ...idle,
162
174
  ...(action === undefined ? {} : { action })
163
175
  })
164
176
  })
@@ -169,9 +181,6 @@ const readInputs = (
169
181
  ): Effect.Effect<ReadonlyArray<TreeInput>, FlowError> =>
170
182
  Effect.map(readTrace(files, target.tracePath), treeInputsOfTrace)
171
183
 
172
- const enterScreen = "\u001b[?1049h\u001b[?25l"
173
- const leaveScreen = "\u001b[?25h\u001b[?1049l"
174
- const home = "\u001b[H\u001b[2J"
175
184
  const pollInterval = Duration.millis(500)
176
185
  const replayGapCap = 2_000
177
186
 
@@ -184,17 +193,22 @@ const draw = (state: TreeState, view: TreeView, output: WatchOutput, height?: nu
184
193
  }).join("\n")
185
194
 
186
195
  /** A full-screen frame: no taller than the terminal. */
187
- const drawScreen = (state: TreeState, view: TreeView, output: WatchOutput): string =>
188
- draw(state, view, output, output.rows())
196
+ const screenLines = (
197
+ state: TreeState,
198
+ view: TreeView,
199
+ output: WatchOutput
200
+ ): ReadonlyArray<string> => draw(state, view, output, output.rows()).split("\n")
189
201
 
190
202
  export const makeWatchProgram = Effect.fn("@llm4ts/runner/Watch.make")(function* (
191
203
  options: WatchOptions,
192
204
  dependencies: WatchDependencies
193
205
  ): Effect.fn.Return<void, FlowError | WatchTargetMissing> {
194
- const target = yield* resolveTarget(options, dependencies)
206
+ const target = yield* resolveTraceTarget(options, dependencies)
195
207
  const { files, output } = dependencies
196
208
  const fold = (inputs: ReadonlyArray<TreeInput>): Effect.Effect<TreeState, FlowError> =>
197
- Effect.map(startingTree(target, files), (start) => inputs.reduce(reduceTree, start))
209
+ Effect.map(startingTree(target, files, options.idleAfterMs), (start) =>
210
+ inputs.reduce(reduceTree, start)
211
+ )
198
212
 
199
213
  if (!output.interactive) {
200
214
  const state = yield* Effect.flatMap(readInputs(target, files), fold)
@@ -236,13 +250,26 @@ export const makeWatchProgram = Effect.fn("@llm4ts/runner/Watch.make")(function*
236
250
  Effect.as(Effect.sleep(wait), false)
237
251
  )
238
252
 
253
+ const painter = yield* makeScreenPainter
239
254
  yield* output.write(enterScreen)
240
255
  yield* Effect.gen(function* () {
241
256
  while (true) {
242
257
  const current = yield* Ref.get(inputs)
243
258
  const count = yield* Ref.get(shown)
244
- const state = yield* fold(current.slice(0, count))
245
- yield* output.write(`${home}${drawScreen(state, yield* Ref.get(view), output)}`)
259
+ const folded = yield* fold(current.slice(0, count))
260
+ // Following a live run, the clock moves between events; a replay
261
+ // and a finished run keep the trace's own time.
262
+ const state =
263
+ options.replay !== true && folded.ended === undefined
264
+ ? reduceTree(folded, { _tag: "Tick", at: yield* Clock.currentTimeMillis })
265
+ : folded
266
+ const text = yield* painter.paint(
267
+ screenLines(state, yield* Ref.get(view), output),
268
+ `${output.columns()}x${output.rows()}`
269
+ )
270
+ if (text.length > 0) {
271
+ yield* output.write(text)
272
+ }
246
273
  const replaying = options.replay === true && count < current.length
247
274
  const gap = replaying
248
275
  ? Math.min(replayGapCap, (current[count]?.at ?? 0) - (current[count - 1]?.at ?? 0)) /
@@ -271,6 +298,12 @@ const nodeListEpics = (directory: string): ReadonlyArray<string> =>
271
298
  ? readdirSync(directory).filter((name) => statSync(join(directory, name)).isDirectory())
272
299
  : []
273
300
 
301
+ export const nodeTraceSources: TraceSources = {
302
+ files: nodePlainFileStore,
303
+ listTraces: nodeListTraces,
304
+ listEpics: nodeListEpics
305
+ }
306
+
274
307
  export const nodeWatchDependencies = (
275
308
  environment: Readonly<Record<string, string | undefined>> = process.env
276
309
  ): WatchDependencies => {