mikser-io 10.4.0 → 10.6.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/app.js CHANGED
@@ -54,11 +54,11 @@ function locate(argv) {
54
54
  // cycle reports drift that is not there.
55
55
  const tool = value('--tool')
56
56
  const explain = value('--explain')
57
- const request = has('--tools') ? { type: 'report', tools: true, json: has('--json') }
58
- : tool ? { type: 'report', tool, toolArgs: value('--tool-args'), json: has('--json') }
59
- : explain ? { type: 'report', explain, json: has('--json') }
60
- : has('--audit-output') ? { type: 'report', auditOutput: true, json: has('--json') }
61
- : has('--fingerprint') ? { type: 'report', fingerprint: true, json: has('--json') }
57
+ const request = has('--tools') ? { type: 'report', tools: true, json: has('--json'), log: value('--log') ?? value('-l') }
58
+ : tool ? { type: 'report', tool, toolArgs: value('--tool-args'), json: has('--json'), log: value('--log') ?? value('-l') }
59
+ : explain ? { type: 'report', explain, json: has('--json'), log: value('--log') ?? value('-l') }
60
+ : has('--audit-output') ? { type: 'report', auditOutput: true, json: has('--json'), log: value('--log') ?? value('-l') }
61
+ : has('--fingerprint') ? { type: 'report', fingerprint: true, json: has('--json'), log: value('--log') ?? value('-l') }
62
62
  : { type: 'build',
63
63
  clear: has('--clear'),
64
64
  // Not a flag that happens to be set — the client's OUTPUT
@@ -74,7 +74,16 @@ function locate(argv) {
74
74
  // rebuilt whatever the gates let through, which on a settled tree
75
75
  // is nothing, and a caller asking for a full re-render got a no-op
76
76
  // reported as success.
77
- force: has('--force', '-f') }
77
+ force: has('--force', '-f'),
78
+ // The level travels for the same reason --json does: it is an
79
+ // OUTPUT CONTRACT, and the process doing the writing is the
80
+ // instance, which was started without it. A forwarded --log
81
+ // otherwise changed the level of a process that prints nothing.
82
+ log: value('--log') ?? value('-l'),
83
+ // Instance state rather than a contract: these outlive the request
84
+ // on purpose, which is the case a per-request flag cannot serve.
85
+ logInstall: value('--log-install'),
86
+ logReset: has('--log-reset') }
78
87
 
79
88
  return {
80
89
  longRunning,
@@ -54,8 +54,9 @@ These options are part of `runtime.options` and apply to the engine itself.
54
54
  | `force` | `-f, --force` | boolean | `false` | Rebuild everything; disable incremental dispatch. |
55
55
  | `resume` | `-R, --resume` | boolean | `false` | Continue from journal entries left by a previous interrupted run; skip the initial filesystem scan. The journal table survives crashes, so an interrupted cycle can be picked up by re-running with `--resume`. |
56
56
  | `verify` | `--audit-output` | boolean | `false` | Verify the output folder against the manifest snapshot — report drift instead of building. |
57
- | `debug` | `-d, --debug` | boolean | `false` | Enable debug-level logging. |
58
- | `trace` | `-t, --trace` | boolean | `false` | Enable trace-level logging (very verbose). |
57
+ | `log` | `-l, --log` | string | — | Log level for this run: `trace`, `debug`, `info`, `notice`, `warn`, `error`, `fatal`, `silent`. Replaces the old `--debug` / `--trace` booleans, which could not express "warnings only" and, in `--debug`'s case, did nothing at all — it moved the logger's level while the terminal stream kept the one it was built with. |
58
+ | `logInstall` | `--log-install` | string | | Set the level on a **running** instance, so its own rebuilds are verbose too — the case a per-request flag cannot serve. Expires after 30 minutes, dies with the process, and is disclosed in the build report under `logLevel`. |
59
+ | `logReset` | `--log-reset` | boolean | `false` | Return a running instance to its configured level. |
59
60
  | `threads` | — | number | `4` | Worker thread count for the Piscina pools (`renderWorkers`, `postprocessWorkers`). Both pools are lazy (`minThreads: 0` + `idleTimeout: 30_000`) so INLINE-only workloads spin up zero workers. |
60
61
  | `server` | `-s, --server [port]` | number\|boolean | — | When set, the engine creates a shared Express app on `runtime.options.app` and listens on the given port (default `3001`) after all plugins have mounted their routes. Plugins like `api` attach to it instead of starting their own server. The `outputFolder` is also served as a static catch-all route at `/` (plugin routes match first; anything that doesn't match falls through to the rendered output). Requires `express` to be installed. |
61
62
  | `junk` | — | array\|false | built-in list | OS and file-manager litter, filtered out of both the scan and the watcher. The dot-prefixed files (`.DS_Store`, `._*`) were already invisible — globby defaults to `dot: false` and the watcher ignores leading dots — but the Windows ones are **not** dotfiles: `Thumbs.db` and `desktop.ini` were measurably scanned *and* watched, and became entities. The list is deliberately conservative (OS/file-manager artifacts and application lock files only, no `*.tmp`, `*.bak` or editor backups), because a filter that silently drops content is worse than the litter it prevents. `false` disables it; an array replaces it. See `isJunkPath` / `JUNK_IGNORE` in `src/utils.js`. Plugins that write metadata next to content add their own patterns with `registerJunk({ ignore, match })` — the engine provides the mechanism and the plugin the knowledge of what its files are called (`mikser-io-drive` registers `*.nephelemeta`). Plugin registrations survive an array override, since narrowing the OS list is not a request to start importing a library's sidecars. |
@@ -328,7 +328,7 @@ compare against. It never appears alongside `matched` or `dependency`
328
328
  either: a consumer switches on `reason` and reads one field, so a stray
329
329
  key from another branch would make that switch wrong.
330
330
 
331
- The same detail appears at `--debug` for a watch run, one line per render.
331
+ The same detail appears at `--log debug` for a watch run, one line per render.
332
332
  It is deliberately not in the build's normal output — the counts are the
333
333
  summary and `--json` is the record — but when you are watching one page
334
334
  misbehave, the trigger is the point.
@@ -419,7 +419,9 @@ thing `find out -type f` cannot do.
419
419
  | `-f, --force` | ignore all three gates (import checksum, dispatch, manifest) and re-render everything |
420
420
  | `-R, --resume` | continue from a previous interrupted run's journal; skips the filesystem scan |
421
421
  | `-r, --clear` | clear state before running |
422
- | `-d, --debug` / `-t, --trace` | raise log level; `trace` includes per-entity catalog writes |
422
+ | `-l, --log <level>` | set the level for this run; `trace` includes per-entity catalog writes |
423
+ | `--log-install <level>` | raise the level on a RUNNING instance without restarting it — restarting drops every connected MCP and drive session, so the tool you need should not require the risky act you are diagnosing. Expires after 30 minutes, dies with the process, and appears in the build report under `logLevel` so a level left on production is findable. |
424
+ | `--log-reset` | put a running instance back to its configured level |
423
425
  | `--tools` | list the registered tools, then exit; `--json` for full schemas |
424
426
  | `--tool <name>` | run one tool and print its result, then exit. `--tool-args '<json>'` supplies arguments |
425
427
 
@@ -114,8 +114,11 @@ mikser [options]
114
114
  interrupted run; skip the initial filesystem scan
115
115
  --audit-output Audit output against recorded snapshots; report
116
116
  drift instead of building
117
- -d, --debug Show debug log statements
118
- -t, --trace Show trace log statements
117
+ -l, --log <level> Log level for this run: trace, debug, info,
118
+ notice, warn, error, fatal, silent
119
+ --log-install <level> Set the level on a RUNNING instance, so its
120
+ own rebuilds are verbose too. Expires.
121
+ --log-reset Return an instance to its configured level
119
122
  -e, --runtime-folder <folder> Runtime/temp folder (default: runtime)
120
123
  ```
121
124
 
package/docs/lifecycle.md CHANGED
@@ -91,7 +91,7 @@ onInitialized(async () => {
91
91
 
92
92
  **What Mikser does here:**
93
93
  - Parses CLI arguments and merges into `runtime.options`
94
- - Sets logger level based on `--debug` / `--trace` flags
94
+ - Sets the logger level from `--log <level>`, moving the terminal stream with it
95
95
  - Resolves absolute paths for `workingFolder`, `outputFolder`, `runtimeFolder`
96
96
  - Creates the `runtimeFolder` directory
97
97
  - Clears `outputFolder` and `runtimeFolder` if `--clear` was set
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "10.4.0",
3
+ "version": "10.6.0",
4
4
  "files": [
5
5
  "app.js",
6
6
  "index.js",
package/src/engine.js CHANGED
@@ -24,7 +24,10 @@ import map from 'p-map'
24
24
  import Queue from 'p-queue'
25
25
  import packageInfo from '../package.json' with { type: 'json' }
26
26
  import { attachServerCliOptions, setupServer } from './server.js'
27
- import { createMikserLogger } from './logger.js'
27
+ import {
28
+ createMikserLogger, rememberBaseLevel, applyLogRequest,
29
+ LOG_LEVELS, INSTALLED_LOG_TTL_MS,
30
+ } from './logger.js'
28
31
  import { inputHashOf } from './utils.js'
29
32
  import { createTrack, mergeTrack } from './track.js'
30
33
  import { queryContext } from './database/query-context.js'
@@ -552,8 +555,19 @@ export async function setup(options) {
552
555
  .option('--fingerprint', 'hash everything this build wrote — including what it wrote through a '
553
556
  + 'symlink, which `find` does not descend into — and exit. One comparable number per output '
554
557
  + 'tree, plus one per asset preset, for proving an upgrade moved no bytes.', false)
555
- .option('-d --debug', 'display debug statements')
556
- .option('-t --trace', 'display trace statements')
558
+ // One level, not two booleans.
559
+ //
560
+ // `--debug` and `--trace` could not say "warnings only on this
561
+ // build" or "trace this one thing", and --debug did not work at
562
+ // all: it moved the logger's level while the terminal stream kept
563
+ // the one it was built with, so debug records were accepted and
564
+ // discarded. Both are gone rather than aliased — a flag that lies
565
+ // is worse than a flag that is missing.
566
+ .option('-l --log <level>', `log level for this run: ${LOG_LEVELS.join(', ')}`)
567
+ .option('--log-install <level>', 'set the log level on a RUNNING instance, so its own '
568
+ + `rebuilds are verbose too. Expires after ${INSTALLED_LOG_TTL_MS / 60000} minutes and `
569
+ + 'dies with the process. Levels as above.')
570
+ .option('--log-reset', 'return a running instance to its configured log level', false)
557
571
  .option('-e --runtime-folder <folder>', 'set mikser runtime folder relative to working folder', 'runtime')
558
572
  attachServerCliOptions(runtime.engine.commander)
559
573
 
@@ -666,16 +680,20 @@ The full version, with what each code means: docs/diagnostics.md`)
666
680
  // runtime.options.info gates the progress bar — gauge stays
667
681
  // silent in --debug/--trace modes because logs are voluminous
668
682
  // there and a bar on top would just be noise.
683
+ //
684
+ // Applied through setLogLevel so the terminal STREAM moves with the
685
+ // logger — the whole reason --debug did nothing.
669
686
  runtime.options.info = true
670
- if (runtime.options.debug) {
671
- runtime.engine.logger.level = 'debug'
672
- runtime.options.info = false
673
- }
674
- if (runtime.options.trace) {
675
- runtime.engine.logger.level = 'trace'
676
- runtime.options.debug = false
677
- runtime.options.info = false
678
- }
687
+ const asked = runtime.options.log
688
+ // The same call the instance makes for a forwarded request — see
689
+ // applyLogRequest. Two implementations drifted within one commit.
690
+ const logRefusal = applyLogRequest({
691
+ log: asked,
692
+ logInstall: runtime.options.logInstall,
693
+ logReset: runtime.options.logReset,
694
+ })
695
+ if (logRefusal) throw new Error(logRefusal)
696
+ rememberBaseLevel(asked && LOG_LEVELS.includes(asked) ? asked : 'info')
679
697
 
680
698
  // Resolve folders inside onInitialize so journal.js and
681
699
  // catalog.js (which initialize in onInitialized) see absolute
package/src/instance.js CHANGED
@@ -37,6 +37,9 @@ import runtime from './runtime.js'
37
37
  import { onLoaded } from './lifecycle.js'
38
38
  import { renderErrorCount } from './report.js'
39
39
  import { runReportOnly } from './engine.js'
40
+ import {
41
+ setLogLevel, restingLogLevel, applyLogRequest, LOG_LEVELS,
42
+ } from './logger.js'
40
43
  import { emitReport } from './report.js'
41
44
  import { pluginOptionsFrom } from './cli.js'
42
45
 
@@ -237,6 +240,17 @@ function captureOutput(onChunk) {
237
240
  // than by content hash — the hash would require the client to import its
238
241
  // config, which is most of the startup forwarding exists to skip, and it is
239
242
  // not what tells the two apart. Different configs are different files.
243
+ // Does this request name a level that does not exist? Pure, so the refusal
244
+ // can be decided before anything is applied.
245
+ function logRequestRefusal(request) {
246
+ for (const [flag, level] of [['--log', request?.log], ['--log-install', request?.logInstall]]) {
247
+ if (level !== undefined && level !== null && !LOG_LEVELS.includes(level)) {
248
+ return `${flag} ${level}: no such level. Levels: ${LOG_LEVELS.join(', ')}`
249
+ }
250
+ }
251
+ return null
252
+ }
253
+
240
254
  function configMismatch(theirs) {
241
255
  if (!theirs) return null
242
256
  const mine = path.resolve(runtime.options.config ?? 'mikser.config.js')
@@ -366,6 +380,26 @@ function refuseClear(socket, request) {
366
380
  return true
367
381
  }
368
382
 
383
+ // A level the instance cannot use.
384
+ //
385
+ // Validated where the request ARRIVES rather than where it is applied, because
386
+ // applying it is exactly what a bad level cannot do: setLogLevel returns false
387
+ // and, before this, nobody read it — so `--log chatty` exited 1 locally and
388
+ // built normally with a watcher up. The refusal frame is how the instance says
389
+ // no to everything else, and a client that mistypes a level deserves the same
390
+ // answer whether or not something happens to be listening.
391
+ function refuseLogLevel(socket, request) {
392
+ const refusal = logRequestRefusal(request)
393
+ if (!refusal) return false
394
+ frame(socket, {
395
+ type: 'refused',
396
+ reason: refusal,
397
+ detail: 'The instance would otherwise have built normally and said nothing, which is the '
398
+ + 'forwarded-versus-local split this surface exists to remove.',
399
+ })
400
+ return true
401
+ }
402
+
369
403
  // Which process to restart.
370
404
  //
371
405
  // "Restart it" is only actionable if you know which `it` — and the machine
@@ -418,11 +452,32 @@ async function withRequestOutput(request, run) {
418
452
  // Set for the duration of the request and restored with the rest, so a
419
453
  // watcher's own cycle before or after is unaffected.
420
454
  request = { ...request, requested: true }
421
- for (const key of ['json', 'tool', 'tools', 'requested']) {
455
+
456
+ // The level the CLIENT asked for, for this request only.
457
+ //
458
+ // Restored in the finally below like every other part of the contract, so
459
+ // a watcher's own cycle before or after keeps the level it was running at.
460
+ // An installed level is different and deliberately not touched here — it
461
+ // outlives the request, which is its whole point.
462
+ // Captured BEFORE applyLogRequest, which writes `info`.
463
+ //
464
+ // `info` is here because --log silent turns the progress bar off through
465
+ // it, and without a restore one silent request left the instance with no
466
+ // bar for the rest of its life. Capturing after the call would have
467
+ // recorded the value the call just wrote and restored nothing — the same
468
+ // shape of mistake, one line further on.
469
+ for (const key of ['json', 'tool', 'tools', 'requested', 'info']) {
422
470
  prior[key] = runtime.options[key]
423
471
  if (request[key]) runtime.options[key] = request[key]
424
472
  }
425
473
 
474
+ // The same call the argv path makes — see applyLogRequest. The level was
475
+ // validated at arrival by refuseLogLevel, so a refusal here is impossible;
476
+ // it is asserted rather than ignored, because "cannot happen" is how the
477
+ // first version of this silently accepted a bad level.
478
+ const refusal = applyLogRequest(request)
479
+ if (refusal) throw new Error(refusal)
480
+
426
481
  // Whatever the client's argv said about a PLUGIN's options.
427
482
  //
428
483
  // The instance parsed its own argv and never saw the client's, so a flag a
@@ -437,6 +492,11 @@ async function withRequestOutput(request, run) {
437
492
  return await run()
438
493
  } finally {
439
494
  Object.assign(runtime.options, prior)
495
+ // Back to where the level RESTS, not to what it was a moment ago.
496
+ // An installed level survives the request; a per-request --log does
497
+ // not; and a --log-reset in this same request has already moved the
498
+ // resting point, so restoring "the previous level" would undo it.
499
+ setLogLevel(restingLogLevel())
440
500
  }
441
501
  }
442
502
 
@@ -539,6 +599,7 @@ export function serveInstance() {
539
599
  // writes anything.
540
600
  if (refuseUnknownFlags(socket, request)) return
541
601
  if (refuseClear(socket, request)) return
602
+ if (refuseLogLevel(socket, request)) return
542
603
  const wrongConfig = configMismatch(request.config)
543
604
  if (wrongConfig) return refuseConfig(socket, request, wrongConfig)
544
605
  const movedFile = await configStale()
package/src/logger.js CHANGED
@@ -30,7 +30,7 @@ import Gauge from 'gauge'
30
30
  import { Writable } from 'node:stream'
31
31
  import runtime from './runtime.js'
32
32
  import { useLogger } from './engine.js'
33
- import { onLoad } from './lifecycle.js'
33
+ import { onLoad, onFinalized } from './lifecycle.js'
34
34
  import { captureWarning, captureFault } from './report.js'
35
35
 
36
36
  // Custom level — `notice` slots between info and warn. Used by mikser
@@ -355,6 +355,111 @@ export function addLogTransport(entry) {
355
355
  return true
356
356
  }
357
357
 
358
+ // The levels a person can ask for, in pino's order.
359
+ export const LOG_LEVELS = ['trace', 'debug', 'info', 'notice', 'warn', 'error', 'fatal', 'silent']
360
+
361
+ // What the run was configured with, so a reset has something to return to.
362
+ let baseLevel = 'info'
363
+ // { level, expiresAt } — set by --log-install, survives the request that set it.
364
+ let installedLevel = null
365
+
366
+ // Move the level, and mean it.
367
+ //
368
+ // `--debug` used to set runtime.engine.logger.level and stop there, which did
369
+ // nothing observable: the pino INSTANCE accepted debug records while the
370
+ // terminal stream still filtered them at the level it was constructed with,
371
+ // so they were accepted and discarded. It only ever worked when a logging
372
+ // transport happened to be configured, because that is the one path that
373
+ // rebuilt the logger. Measured on 10.4.0: identical output with and without
374
+ // the flag, down to the line count.
375
+ //
376
+ // So the stream entry moves too, and the instance is rebuilt over the live
377
+ // stream list the way addLogTransport already does — same swap, so a transport
378
+ // added earlier survives.
379
+ //
380
+ // TRANSPORTS KEEP THEIR OWN LEVEL. They were built with the level they
381
+ // declared, and `--log debug` is a statement about what the operator wants to
382
+ // SEE, not an instruction to flood Better Stack. A transport that wants more
383
+ // says so in its own entry.
384
+ export function setLogLevel(level) {
385
+ if (!LOG_LEVELS.includes(level)) return false
386
+ if (currentStreams === null || !runtime.engine) return false
387
+ // Index 0 is the terminal entry — see createMikserLogger, where the list
388
+ // is seeded with it before any transport is appended.
389
+ currentStreams[0] = { ...currentStreams[0], level }
390
+ currentLevel = level
391
+ runtime.engine.logger = pino(
392
+ { level: 'trace', customLevels: CUSTOM_LEVELS },
393
+ pino.multistream(currentStreams),
394
+ )
395
+ return true
396
+ }
397
+
398
+ // The level a run starts at, remembered so --log-reset has a target.
399
+ export function rememberBaseLevel(level) {
400
+ if (LOG_LEVELS.includes(level)) baseLevel = level
401
+ }
402
+
403
+ // Raise the level on a RUNNING instance, until it expires.
404
+ //
405
+ // The case a per-request flag structurally cannot serve: a watcher's own
406
+ // rebuilds. Today the only way to make a misbehaving production instance
407
+ // verbose is to restart it — which drops every connected MCP and drive
408
+ // session, and is the incident path, so the tool you need is available only by
409
+ // performing the risky act you are trying to diagnose.
410
+ //
411
+ // EXPIRES, because the failure mode is a full disk weeks later with nobody
412
+ // remembering who asked. It also dies with the process, so a restart is a
413
+ // second guarantee rather than the only one.
414
+ export function installLogLevel(level, ttlMs = INSTALLED_LOG_TTL_MS) {
415
+ if (!LOG_LEVELS.includes(level)) return false
416
+ installedLevel = { level, expiresAt: Date.now() + ttlMs }
417
+ return setLogLevel(level)
418
+ }
419
+
420
+ export function resetLogLevel() {
421
+ installedLevel = null
422
+ return setLogLevel(baseLevel)
423
+ }
424
+
425
+ // What is in force, and whether an installed level has run out. Called at the
426
+ // top of a cycle so expiry lands on a build boundary rather than mid-render.
427
+ export function applyInstalledLogLevel() {
428
+ if (!installedLevel) return null
429
+ if (Date.now() >= installedLevel.expiresAt) {
430
+ const expired = installedLevel.level
431
+ installedLevel = null
432
+ setLogLevel(baseLevel)
433
+ return { expired, level: baseLevel }
434
+ }
435
+ if (currentLevel !== installedLevel.level) setLogLevel(installedLevel.level)
436
+ return { level: installedLevel.level, expiresAt: installedLevel.expiresAt }
437
+ }
438
+
439
+ // Where the level RESTS between requests: an installed one if there is one,
440
+ // otherwise what the run was configured with.
441
+ //
442
+ // The single rule the restore needs. Putting back "the level before this
443
+ // request" instead was wrong for exactly one case and it was the important
444
+ // one: --log-reset captured debug, cleared it, and the restore put debug back,
445
+ // so the reset appeared to do nothing.
446
+ export function restingLogLevel() {
447
+ return installedLevel?.level ?? baseLevel
448
+ }
449
+
450
+ // What is in force right now, so a caller can put it back.
451
+ export function currentLogLevel() {
452
+ return currentLevel ?? baseLevel
453
+ }
454
+
455
+ export function installedLogLevel() {
456
+ return installedLevel ? { ...installedLevel } : null
457
+ }
458
+
459
+ // Thirty minutes: long enough to reproduce something on a live instance,
460
+ // short enough that forgetting costs a log file rather than a disk.
461
+ export const INSTALLED_LOG_TTL_MS = 30 * 60 * 1000
462
+
358
463
  // Replace the bootstrap logger (built by engine.setup() with the
359
464
  // terminal-only stream) with one that includes any third-party
360
465
  // transports from runtime.config.logging.transports. Runs at onLoad
@@ -386,16 +491,49 @@ export function trackProgress(name, total) {
386
491
  if (!name || !total) return
387
492
  const logger = useLogger()
388
493
  logger.debug('%s started: %d', name, total)
389
- if (!process.stdout.isTTY || !runtime.options.info) return
390
- currentBar = { name, total, value: 0, started: Date.now() }
391
- ensureGauge().show({ section: name, subsection: `0/${total}` }, 0)
494
+ // The bar writes to stdout, and stdout is where --json and --tool put
495
+ // their DOCUMENT. Forwarded, those writes are captured and framed to the
496
+ // client, so the gauge landed inside the JSON:
497
+ // `^[[?25lDocuments import: >416/800` at byte 0, and JSON.parse threw —
498
+ // 4 runs in 10 at the default level on an 800-document corpus.
499
+ //
500
+ // Checked HERE rather than through runtime.options.info, because this is
501
+ // the actual invariant and info is a preference. A preference can be
502
+ // forgotten on a path; an invariant stated at the one place a bar starts
503
+ // cannot.
504
+ const carriesDocument = runtime.options?.json || runtime.options?.tool || runtime.options?.tools
505
+
506
+ // TRACKED always, DRAWN only where a bar belongs.
507
+ //
508
+ // The two used to be one decision, so anything that could not draw also
509
+ // stopped counting — and `stopProgress` returns early without a bar, which
510
+ // is where the "finished: N in Ns" line comes from. A piped build
511
+ // therefore reported no phase timings at all, and suppressing the bar for
512
+ // --json would have extended that to every machine reading the output.
513
+ // Losing the graphics is the point; losing the information is not.
514
+ const drawn = !carriesDocument && Boolean(process.stdout.isTTY) && Boolean(runtime.options.info)
515
+ currentBar = { name, total, value: 0, started: Date.now(), drawn, milestone: 0 }
516
+ if (drawn) ensureGauge().show({ section: name, subsection: `0/${total}` }, 0)
517
+ else logger.info({ code: 'progress', phase: name, total, value: 0 }, '%s: 0/%d', name, total)
392
518
  }
393
519
 
394
520
  export function updateProgress() {
395
521
  if (!currentBar) return
396
522
  currentBar.value++
397
- const { name, total, value } = currentBar
398
- gauge?.show({ section: name, subsection: `${value}/${total}` }, value / total)
523
+ const { name, total, value, drawn } = currentBar
524
+ if (drawn) {
525
+ gauge?.show({ section: name, subsection: `${value}/${total}` }, value / total)
526
+ } else {
527
+ // Quartiles, not every item: a bar redraws in place and costs one
528
+ // line, a log record does not. 800 documents is 800 lines of noise if
529
+ // this counts the way the bar does.
530
+ const reached = Math.floor((value / total) * 4)
531
+ if (reached > currentBar.milestone && value < total) {
532
+ currentBar.milestone = reached
533
+ useLogger()?.info({ code: 'progress', phase: name, total, value },
534
+ '%s: %d/%d', name, value, total)
535
+ }
536
+ }
399
537
  if (value >= total) stopProgress()
400
538
  }
401
539
 
@@ -404,11 +542,15 @@ export function stopProgress() {
404
542
  const logger = useLogger()
405
543
  const { name, total, value, started } = currentBar
406
544
  gauge?.hide()
545
+ // Structured either way, so a machine reading --json's stderr gets the
546
+ // same facts a person reads off the bar.
407
547
  if (value < total) {
408
- logger.warn('%s unfinished: %d', name, total - value)
548
+ logger.warn({ code: 'progress-unfinished', phase: name, total, value, missing: total - value },
549
+ '%s unfinished: %d', name, total - value)
409
550
  } else {
410
- const elapsed = Math.round((Date.now() - started) / 1000)
411
- logger.info('%s finished: %d %ds', name, total, elapsed)
551
+ const ms = Date.now() - started
552
+ logger.info({ code: 'progress-finished', phase: name, total, ms },
553
+ '%s finished: %d %ds', name, total, Math.round(ms / 1000))
412
554
  }
413
555
  currentBar = null
414
556
  }
@@ -422,3 +564,70 @@ export function updateProgressDetails(details) {
422
564
  currentBar.value / currentBar.total,
423
565
  )
424
566
  }
567
+
568
+ // What a caller asked for about logging, applied the same way from argv and
569
+ // from a forwarded request.
570
+ //
571
+ // It was written twice and the copies drifted immediately: the argv path threw
572
+ // on an unknown level and set `info`, the forwarded path called setLogLevel and
573
+ // ignored the false it returns. So `--log chatty` exited 1 locally and built
574
+ // normally with a watcher up, and `--log silent` left the progress bar running
575
+ // on an instance. The same forwarded/local split --json and --force each had,
576
+ // and this feature's own argument against itself: a flag that lies is worse
577
+ // than a flag that is missing.
578
+ //
579
+ // Returns an error STRING rather than throwing, because the two callers need
580
+ // different things from a failure — argv throws, the instance refuses over the
581
+ // socket — and a shared implementation should not decide that for them.
582
+ export function applyLogRequest({ log, logInstall, logReset } = {}) {
583
+ for (const [flag, level] of [['--log', log], ['--log-install', logInstall]]) {
584
+ if (level !== undefined && level !== null && !LOG_LEVELS.includes(level)) {
585
+ return `${flag} ${level}: no such level. Levels: ${LOG_LEVELS.join(', ')}`
586
+ }
587
+ }
588
+ if (logReset) resetLogLevel()
589
+ if (logInstall) installLogLevel(logInstall)
590
+ if (log) setLogLevel(log)
591
+
592
+ // A bar on top of debug output is noise, and silent means silent.
593
+ const level = log || logInstall
594
+ if (level === 'trace' || level === 'debug' || level === 'silent') {
595
+ runtime.options.info = false
596
+ }
597
+ return null
598
+ }
599
+
600
+ // An installed level, disclosed and expired, once per cycle.
601
+ //
602
+ // Disclosed for the reason an installed command is: it is state left on a live
603
+ // instance that changes what the process does, and the person who finds it
604
+ // weeks later is not the person who set it. A level at debug is quieter to
605
+ // leave behind than a probe and louder in effect — the deployment's out log is
606
+ // already 1.6MB, and a watcher rebuilding on every editor save at debug grows
607
+ // it fast. The failure lands as a full disk with nobody remembering who asked.
608
+ //
609
+ // Expiry is checked here rather than on a timer so it lands on a build
610
+ // boundary instead of mid-render, and the level also dies with the process, so
611
+ // a restart is a second guarantee rather than the only one.
612
+ //
613
+ // onFinalized, matching where the commands plugin announces an installed
614
+ // command — the report is reset at the top of a cycle, and a warning raised
615
+ // anywhere earlier than the last hook did not survive into the document a
616
+ // forwarded --json emits. Measured, not assumed: on onImport and on onFinalize
617
+ // the line reached the instance's log and the report stayed empty.
618
+ onFinalized(() => {
619
+ const state = applyInstalledLogLevel()
620
+ if (!state) return
621
+ const logger = useLogger()
622
+ if (state.expired) {
623
+ logger?.info('Log level installed with --log-install has expired; back to %s', state.level)
624
+ return
625
+ }
626
+ const minutes = Math.max(0, Math.round((state.expiresAt - Date.now()) / 60000))
627
+ logger?.warn(
628
+ { code: 'log-level-installed', level: state.level, expiresIn: `${minutes}m` },
629
+ 'This instance is running at log level %s, installed with --log-install — it is not the '
630
+ + 'configured level and it is not this build asking for it. Expires in %dm, or on --log-reset, '
631
+ + 'or when the process restarts.',
632
+ state.level, minutes)
633
+ })
package/src/report.js CHANGED
@@ -10,6 +10,7 @@
10
10
  // reworded — which is exactly the kind of assertion that should not break
11
11
  // when someone improves the wording.
12
12
  import runtime from './runtime.js'
13
+ import { installedLogLevel } from './logger.js'
13
14
  import { isReportOnlyRun } from './tools.js'
14
15
 
15
16
  // A transport that can serve the build report declares itself here, at
@@ -529,6 +530,27 @@ export function buildReport() {
529
530
  cycleId: cycle?.id ?? null,
530
531
  startedAt: cycle?.startedAt ?? null,
531
532
  finishedAt: cycle?.finishedAt ?? null,
533
+ // A log level installed on this instance with --log-install.
534
+ //
535
+ // Read HERE, at emit time, rather than raised as a warning during the
536
+ // cycle. A warning was the obvious shape — it is how an installed
537
+ // command is disclosed — and it does not survive into the document a
538
+ // forwarded --json emits: the line reached the instance's log from
539
+ // onImport, onFinalize and onFinalized alike, and `warnings` stayed
540
+ // empty every time. Reading state at emit has no ordering to get
541
+ // wrong, and this is a FACT ABOUT THE INSTANCE rather than an event in
542
+ // the cycle, so it reads better as a field than as an entry anyway.
543
+ //
544
+ // Null is the normal case, and its absence is what tells a reader the
545
+ // level is the configured one.
546
+ logLevel: installedLogLevel()
547
+ ? {
548
+ level: installedLogLevel().level,
549
+ installed: true,
550
+ expiresInMinutes: Math.max(0,
551
+ Math.round((installedLogLevel().expiresAt - Date.now()) / 60000)),
552
+ }
553
+ : null,
532
554
  // What the run COST, per phase, in milliseconds.
533
555
  //
534
556
  // `finishedAt - startedAt` is not this. It spans the processing cycle