@falling-ts/dsh-force-compact 0.2.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/LICENSE +21 -0
- package/README.cn.md +170 -0
- package/README.md +479 -0
- package/cordis.patch.yml +13 -0
- package/index.js +550 -0
- package/package.json +54 -0
- package/src/core/crashnet.js +215 -0
- package/src/core/log.js +346 -0
- package/src/core/pairing.js +188 -0
- package/src/core/policy.js +35 -0
- package/src/core/projected.js +139 -0
- package/src/core/settings.js +475 -0
- package/src/core/ui-signal.js +271 -0
- package/src/engine/backend.js +143 -0
- package/src/engine/builtin.js +1336 -0
- package/src/engine/checkpoint.js +206 -0
- package/src/engine/region.js +579 -0
- package/src/engine/summarizer.js +818 -0
- package/src/hooks/command.js +164 -0
- package/src/hooks/guard.js +706 -0
- package/src/hooks/idle.js +136 -0
- package/src/hooks/wire-rewrite.js +151 -0
- package/web/client.js +807 -0
- package/web/swish.css +188 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal crash net for every exported method entry in the plugin.
|
|
3
|
+
*
|
|
4
|
+
* Two layers:
|
|
5
|
+
* 1. **Entry wrappers** — every exported function routed through
|
|
6
|
+
* {@link guardFn} gets its ENTIRE body (the topmost level of the call
|
|
7
|
+
* tree) covered: on throw, a detailed diagnostic — function name, the exact
|
|
8
|
+
* throw site (`file:line:column`), the deepest plugin frame, the nearest
|
|
9
|
+
* NON-plugin frame (usually the vendored caller that actually faulted),
|
|
10
|
+
* and the full call stack — is appended to the plugin's durable log file.
|
|
11
|
+
* 2. **Process-wide net** ({@link installCrashNet}) — `uncaughtException` +
|
|
12
|
+
* `unhandledRejection` handlers (installed at most once per process) that
|
|
13
|
+
* classify whatever escapes every entry point the same way.
|
|
14
|
+
*
|
|
15
|
+
* Output goes straight to the durable debug log (same destination convention
|
|
16
|
+
* as `core/log.js`: `~/.dsh/logs/dsh-force-compact.log`), bypassing
|
|
17
|
+
* `ctx.logger` entirely so a crash remains reconstructable even before the
|
|
18
|
+
* logger is wired or after a process death. Every emission site is itself
|
|
19
|
+
* self-defensive: a logger failure can never disturb a business path.
|
|
20
|
+
*
|
|
21
|
+
* @module @falling-ts/dsh-force-compact/crashnet
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs'
|
|
25
|
+
import os from 'node:os'
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
|
|
28
|
+
/** Expand a leading `~` using the process user home (Windows: USERPROFILE). */
|
|
29
|
+
function expandTilde(value) {
|
|
30
|
+
if (typeof value !== 'string') return value
|
|
31
|
+
const trimmed = value.trim()
|
|
32
|
+
if (trimmed !== '~' && !trimmed.startsWith('~/') && !trimmed.startsWith('~\\')) return value
|
|
33
|
+
const rest = trimmed === '~' ? '' : trimmed.slice(2)
|
|
34
|
+
const home = process.env.USERPROFILE || process.env.HOME || (os.homedir && os.homedir())
|
|
35
|
+
if (home === undefined || home === null || typeof home !== 'string' || home.length === 0) return value
|
|
36
|
+
return path.join(home, rest)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Durable crash-log path — identical convention to `core/log.js`. */
|
|
40
|
+
function crashLogPath() {
|
|
41
|
+
try {
|
|
42
|
+
return expandTilde('~/.dsh/logs/dsh-force-compact.log')
|
|
43
|
+
} catch {
|
|
44
|
+
return ''
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Append one line to the durable crash log. Never throws. */
|
|
49
|
+
function appendDiag(line) {
|
|
50
|
+
try {
|
|
51
|
+
const p = crashLogPath()
|
|
52
|
+
if (p === '') return
|
|
53
|
+
fs.mkdirSync(path.dirname(p), { recursive: true })
|
|
54
|
+
fs.appendFileSync(p, line + '\n', 'utf8')
|
|
55
|
+
} catch (_diagnosticFailure) {
|
|
56
|
+
// A diagnostic sink must never break a business path.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True when a stack-frame line points inside this plugin package. */
|
|
61
|
+
function isPluginFrame(frame) {
|
|
62
|
+
return typeof frame === 'string' && frame.indexOf('dsh-force-compact') >= 0
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Extract a human-readable `file:line:col` from one stack-frame line. */
|
|
66
|
+
function frameLocation(frame) {
|
|
67
|
+
if (typeof frame !== 'string') return String(frame)
|
|
68
|
+
const match = /\(?([^(\n]*?)(?:(\d+))?(?:(\d+))?(\))?$/g.exec(frame.trim())
|
|
69
|
+
if (match === null) return frame.trim()
|
|
70
|
+
const file = (match[1] || '').trim()
|
|
71
|
+
const line = match[2]
|
|
72
|
+
const col = match[3]
|
|
73
|
+
if (file === '' && line === undefined) return frame.trim()
|
|
74
|
+
if (line === undefined) return file
|
|
75
|
+
return `${file}:${line}${col === undefined ? '' : ':' + col}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Split a stack string into plugin-side and non-plugin frames.
|
|
80
|
+
* @param {string} stackStack the raw `Error.stack` text.
|
|
81
|
+
* @returns {{ pluginFrames: string[], foreignFrames: string[] }}
|
|
82
|
+
*/
|
|
83
|
+
function partitionFrames(stackStack) {
|
|
84
|
+
const pluginFrames = []
|
|
85
|
+
const foreignFrames = []
|
|
86
|
+
if (typeof stackStack !== 'string') return { pluginFrames, foreignFrames }
|
|
87
|
+
for (const raw of stackStack.split('\n')) {
|
|
88
|
+
const trimmed = raw.trim()
|
|
89
|
+
if (trimmed.length === 0) continue
|
|
90
|
+
if (/(^|\()dsh-force-compact/.test(trimmed)) pluginFrames.push(trimmed)
|
|
91
|
+
else if (/^at /.test(trimmed)) foreignFrames.push(trimmed)
|
|
92
|
+
}
|
|
93
|
+
return { pluginFrames, foreignFrames }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Render the multi-line crash diagnostic for one thrown value.
|
|
98
|
+
* @param {string} label stable display name of the wrapping entry.
|
|
99
|
+
* @param {unknown} error the thrown value.
|
|
100
|
+
* @param {string} site the exact `file:line:col` where the throw originated,
|
|
101
|
+
* or the sentinel marker when unavailable.
|
|
102
|
+
* @returns {string[]} formatted lines ready for the durable log.
|
|
103
|
+
*/
|
|
104
|
+
export function renderCrash(label, error, site) {
|
|
105
|
+
const isErrorLike = error instanceof Error
|
|
106
|
+
const message = isErrorLike ? (error.message ?? '(no message)') : (typeof error === 'string' ? error : (function stringifySafe(v) { try { return JSON.stringify(v) } catch { return String(v) } })(error))
|
|
107
|
+
const stackString = (isErrorLike && typeof error.stack === 'string' && error.stack.length > 0) ? error.stack : ''
|
|
108
|
+
const { pluginFrames, foreignFrames } = partitionFrames(stackString)
|
|
109
|
+
const lines = []
|
|
110
|
+
lines.push(`[force-compact][CRASHNET] ENTRY FAILURE — ${label}`)
|
|
111
|
+
lines.push(` message: ${message}`)
|
|
112
|
+
lines.push(` thrownAt: ${typeof site === 'string' && site.length > 0 ? site : '(not captured)'}`)
|
|
113
|
+
if (pluginFrames.length > 0) lines.push(` deepest-plugin-frame: ${frameLocation(pluginFrames[pluginFrames.length - 1])}`)
|
|
114
|
+
if (foreignFrames.length > 0) lines.push(` nearest-non-plugin-frame: ${frameLocation(foreignFrames[foreignFrames.length - 1])}`)
|
|
115
|
+
const stackLines = stackString.length > 0 ? stackString.split('\n') : []
|
|
116
|
+
lines.push(` ---- call stack (up to 40 frames) ----`)
|
|
117
|
+
for (const raw of stackLines.slice(0, 40)) lines.push(` ${raw}`)
|
|
118
|
+
if (stackLines.length > 40) lines.push(` …(${stackLines.length - 40} more frames elided)`)
|
|
119
|
+
if (stackLines.length === 0) lines.push(` (no stack captured — the thrown value carried no .stack)`)
|
|
120
|
+
lines.push(` ------------------------------------------`)
|
|
121
|
+
return lines
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Capture the EXACT call site of the current expression: the innermost frame
|
|
126
|
+
* of a freshly minted `Error().stack`. Called from the wrapper's catch block
|
|
127
|
+
* it names `file:line:col` of the statement that threw.
|
|
128
|
+
* @returns {string} the call-site coordinate, or a sentinel on failure.
|
|
129
|
+
*/
|
|
130
|
+
function captureSite() {
|
|
131
|
+
try {
|
|
132
|
+
const raw = new Error('crashnet-site-marker').stack
|
|
133
|
+
if (typeof raw !== 'string' || raw.length === 0) return '(no stack available)'
|
|
134
|
+
const lines = raw.split('\n')
|
|
135
|
+
const idx = lines.findIndex(l => l.indexOf('crashnet-site-marker') >= 0)
|
|
136
|
+
if (idx < 0) return '(marker frame not found)'
|
|
137
|
+
return frameLocation(lines[idx])
|
|
138
|
+
} catch {
|
|
139
|
+
return '(site capture failed)'
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Wrap a method entry with the universal crash net.
|
|
145
|
+
*
|
|
146
|
+
* Semantics:
|
|
147
|
+
* - **Success path**: synchronous bodies resolve synchronously; asynchronous
|
|
148
|
+
* (promise-returning) bodies keep their promise shape. Callers observe
|
|
149
|
+
* exactly what they observe today.
|
|
150
|
+
* - **Failure path**: the wrapper appends the full diagnostic to the durable
|
|
151
|
+
* log AND rethrows/propagates the ORIGINAL error (or promise rejection).
|
|
152
|
+
* Existing `try/catch` blocks in callers keep receiving it — this layer adds
|
|
153
|
+
* observability, it changes no control flow.
|
|
154
|
+
*
|
|
155
|
+
* @template {(...args:any)=>any} F
|
|
156
|
+
* @param {string} label stable display name of the wrapped entry.
|
|
157
|
+
* @param {F} fn the body to cover.
|
|
158
|
+
* @returns {F} the covered body.
|
|
159
|
+
*/
|
|
160
|
+
export function guardFn(label, fn) {
|
|
161
|
+
const wrapped = (...args) => {
|
|
162
|
+
try {
|
|
163
|
+
const result = fn(...args)
|
|
164
|
+
if (result !== null && typeof result === 'object' && typeof result.catch === 'function') {
|
|
165
|
+
// Async entry: attach a rejection observer that LOGS but PRESERVES the
|
|
166
|
+
// rejection (downstream catches still fire). The `.catch` chain returns
|
|
167
|
+
// a new promise; callers awaiting it see the identical outcome.
|
|
168
|
+
result.catch((error) => {
|
|
169
|
+
for (const line of renderCrash(`${label} (async)`, error, captureSite())) appendDiag(line)
|
|
170
|
+
})
|
|
171
|
+
return result
|
|
172
|
+
}
|
|
173
|
+
return result
|
|
174
|
+
} catch (error) {
|
|
175
|
+
for (const line of renderCrash(label, error, captureSite())) appendDiag(line)
|
|
176
|
+
throw error
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
try { Object.defineProperty(wrapped, 'name', { value: label, configurable: true }) } catch { /* cosmetic */ }
|
|
180
|
+
return wrapped
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Guarded installation flag — at most one set of process handlers. */
|
|
184
|
+
let installed = false
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Install the process-wide crash net (idempotent).
|
|
188
|
+
*
|
|
189
|
+
* Attaches one `uncaughtException` handler and one `unhandledRejection`
|
|
190
|
+
* handler that emit a full diagnostic for anything escaping every wrapped
|
|
191
|
+
* entry (native callbacks, microtask roots, timer callbacks). The exception
|
|
192
|
+
* handler KEEPS THE PROCESS ALIVE (Node's default terminates on
|
|
193
|
+
* `uncaughtException`), so the diagnostic line lands before any later
|
|
194
|
+
* supervisor decision. If a different subsystem installs its own handler
|
|
195
|
+
* earlier, ours still fires — multiple handlers compose.
|
|
196
|
+
*/
|
|
197
|
+
export function installCrashNet() {
|
|
198
|
+
if (installed) return
|
|
199
|
+
installed = true
|
|
200
|
+
process.on('uncaughtException', (error) => {
|
|
201
|
+
for (const line of renderCrash('UNCAUGHT EXCEPTION (process-wide net)', error, captureSite())) appendDiag(line)
|
|
202
|
+
})
|
|
203
|
+
process.on('unhandledRejection', (reason) => {
|
|
204
|
+
for (const line of renderCrash('UNHANDLED REJECTION (process-wide net)', reason, captureSite())) appendDiag(line)
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Public accessor: append one line to the durable crash log. */
|
|
209
|
+
export function appendCrashLine(line) { appendDiag(line) }
|
|
210
|
+
|
|
211
|
+
/** Public accessor: capture the current call-site coordinates. */
|
|
212
|
+
export function captureThrowSite() { return captureSite() }
|
|
213
|
+
|
|
214
|
+
/** Public accessor: the resolved crash-log path. */
|
|
215
|
+
export function getCrashLogPath() { return crashLogPath() }
|
package/src/core/log.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-force-compact debug log sink.
|
|
3
|
+
*
|
|
4
|
+
* Installs a `ctx.logger` exporter that routes **this plugin's own** log lines
|
|
5
|
+
* (those whose first argument is tagged `[force-compact]`) to a file — by
|
|
6
|
+
* default `~/.dsh/logs/dsh-force-compact.log` (under the shared user `$DSH_HOME`,
|
|
7
|
+
* kept out of any single checkout) — whenever debug logging is enabled by the
|
|
8
|
+
* `falling-ts-force-compact` settings (`debug`, a boolean that defaults to
|
|
9
|
+
* `true`). This makes the plugin's otherwise-invisible `warn` / `debug`
|
|
10
|
+
* diagnostics land somewhere durable, closing the loop where the stock
|
|
11
|
+
* logger's in-memory-only default sink + `INFO` floor meant those lines went
|
|
12
|
+
* nowhere.
|
|
13
|
+
*
|
|
14
|
+
* Installation is deferred to {@linkcode ensureDebugLogger}, invoked lazily from
|
|
15
|
+
* the guarded listeners. File I/O goes through **Node's native `node:fs`**
|
|
16
|
+
* (dynamically imported), not the product `fs` service — the latter is fenced
|
|
17
|
+
* by the sandbox policy to the session workspace and refuses an absolute path
|
|
18
|
+
* such as `~/.dsh/logs` (which is deliberately kept OUT of any workspace). A
|
|
19
|
+
* diagnostic side-channel to the user home is therefore best served straight
|
|
20
|
+
* from Node, independent of the instance's sandbox mode.
|
|
21
|
+
*
|
|
22
|
+
* Design notes (kept deliberately minimal, per this plugin's conventions):
|
|
23
|
+
* - No `timer` and no long-lived in-memory queue: each captured line performs
|
|
24
|
+
* a single fire-and-forget read-append of the file. Occasional loss or
|
|
25
|
+
* interleaving under concurrent flushes is acceptable for a diagnostic sink
|
|
26
|
+
* and never affects a request path.
|
|
27
|
+
* - The exporter registers with `levels: { default: DEBUG }` so the host's
|
|
28
|
+
* default `INFO` floor no longer drops `warn` / `debug`.
|
|
29
|
+
* - Appends are capped (~{@linkcode MAX_LOG_CHARS}) keeping the tail, so the
|
|
30
|
+
* file cannot grow unbounded.
|
|
31
|
+
* - Every failure is swallowed: a diagnostic sink must never break business
|
|
32
|
+
* paths.
|
|
33
|
+
*
|
|
34
|
+
* @module @falling-ts/dsh-force-compact/debug-log
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { readSettings, DEFAULTS, NS } from './settings.js'
|
|
38
|
+
|
|
39
|
+
/** Marker identifying this plugin's own log lines (matches every `ctx.logger.*('[force-compact] …')` call site). */
|
|
40
|
+
const MARKER = '[force-compact]'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Expand a leading `~` prefix to the absolute OS user home, so `fs.resolve`
|
|
44
|
+
* (which treats `~` as a literal path segment) receives an absolute path.
|
|
45
|
+
*
|
|
46
|
+
* Reuses the harness's own `expandHomePath` (from the resolvable
|
|
47
|
+
* `@deepseek-ai/dsh-home-paths` workspace package, whose `homedir()` honors
|
|
48
|
+
* `USERPROFILE` on Windows) when importable; otherwise falls back to a local
|
|
49
|
+
* expansion driven by whichever user-home environment variable is readable
|
|
50
|
+
* (`USERPROFILE` on Windows, `HOME` elsewhere) without requiring Node globals
|
|
51
|
+
* to be injected. Returns the input unchanged when no supported `~` prefix is
|
|
52
|
+
* present or no home can be determined.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} path
|
|
55
|
+
* @returns {Promise<string>}
|
|
56
|
+
*/
|
|
57
|
+
async function expandHome(path) {
|
|
58
|
+
if (typeof path !== 'string') return path
|
|
59
|
+
const trimmed = path.trim()
|
|
60
|
+
if (trimmed !== '~' && !trimmed.startsWith('~/') && !trimmed.startsWith('~\\')) return path
|
|
61
|
+
const rest = trimmed === '~' ? '' : trimmed.slice(2)
|
|
62
|
+
try {
|
|
63
|
+
const mod = await import('@deepseek-ai/dsh-home-paths')
|
|
64
|
+
const fn = mod.expandHomePath
|
|
65
|
+
if (typeof fn === 'function') return fn(trimmed)
|
|
66
|
+
} catch {
|
|
67
|
+
// Fall through to the local expansion below.
|
|
68
|
+
}
|
|
69
|
+
// Last resort: read the user home from the environment via a dynamically
|
|
70
|
+
// imported `node:os` (reachable from the plugin's loader base), and expand
|
|
71
|
+
// manually if that works too. When none is available, return the input as-is
|
|
72
|
+
// so the caller still attempts a best-effort write rather than throwing.
|
|
73
|
+
const home = await readUserHome()
|
|
74
|
+
if (home === null) return path
|
|
75
|
+
const sep = home.includes('\\') ? '\\' : '/'
|
|
76
|
+
return home + (rest === '' ? '' : sep + rest.replace(/\\/g, sep))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Best-effort OS user home without relying on injected globals: prefer an
|
|
81
|
+
* explicitly-readable home (via dynamically imported `node:os`), else `null`.
|
|
82
|
+
*
|
|
83
|
+
* @returns {Promise<string|null>}
|
|
84
|
+
*/
|
|
85
|
+
async function readUserHome() {
|
|
86
|
+
try {
|
|
87
|
+
const mod = await import('node:os')
|
|
88
|
+
const fn = mod && mod.homedir
|
|
89
|
+
if (typeof fn === 'function') {
|
|
90
|
+
const h = fn.call(mod)
|
|
91
|
+
if (typeof h === 'string' && h.length > 0) return h
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// `node:os` unreachable; leave it to the caller.
|
|
95
|
+
}
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Cap the on-disk log size (characters), keeping the most recent lines. */
|
|
100
|
+
const MAX_LOG_CHARS = 1000000
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Install the debug-log sink for this plugin, **at most once**, called lazily
|
|
104
|
+
* from the guarded listeners.
|
|
105
|
+
*
|
|
106
|
+
* Idempotency: a process-local latch (`debugState`) settles the outcome. On a
|
|
107
|
+
* committed install `installed` becomes `true` and every subsequent call is a
|
|
108
|
+
* cheap early return (no settings read). While a prior attempt has not settled
|
|
109
|
+
* (`attempted` false) a later listener re-enters. The first successful install
|
|
110
|
+
* wins and binds the exporter to its fiber, where it is disposed automatically
|
|
111
|
+
* when the plugin stops, updates, or is removed.
|
|
112
|
+
*
|
|
113
|
+
* Reads the `falling-ts-force-compact` settings live (`debug`, default `true`,
|
|
114
|
+
* and `logFile`, default `~/.dsh/logs/dsh-force-compact.log`), expands a
|
|
115
|
+
* leading `~` to the absolute OS home, and registers a `ctx.logger` exporter
|
|
116
|
+
* that routes only this plugin's own `[force-compact]` lines to the file via
|
|
117
|
+
* native `node:fs` (sandbox-independent). Swallows all failures: a diagnostic
|
|
118
|
+
* sink must never break business paths.
|
|
119
|
+
*
|
|
120
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
121
|
+
* @returns {Promise<void>}
|
|
122
|
+
*/
|
|
123
|
+
export async function ensureDebugLogger(ctx) {
|
|
124
|
+
if (debugState.installed) return
|
|
125
|
+
if (debugState.attempted) return
|
|
126
|
+
debugState.attempted = true
|
|
127
|
+
|
|
128
|
+
// SAFETY ENVELOPE: a diagnostic-sink installer must NEVER break a business
|
|
129
|
+
// path. Wrap the whole install so any anomaly (a throwing
|
|
130
|
+
// `ctx.logger.exporter`, a rejecting settings read, a bad path) marks the
|
|
131
|
+
// sink as settled-installed and moves on silently rather than propagating.
|
|
132
|
+
try {
|
|
133
|
+
await __ensureDebugLoggerBody(ctx)
|
|
134
|
+
} catch {
|
|
135
|
+
debugState.installed = true
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function __ensureDebugLoggerBody(ctx) {
|
|
140
|
+
const resolved = (await readSettings(ctx)) ?? { ...DEFAULTS }
|
|
141
|
+
// Centralize the `debug` gate at the EXPORT boundary: the exporter decides,
|
|
142
|
+
// per line, whether to persist — reading the live `debug` setting at install
|
|
143
|
+
// time (and, on the fast path, trusting the cached flag). A `debug === false`
|
|
144
|
+
// deployment therefore installs nothing and writes nothing: no self-noticing
|
|
145
|
+
// line, no settings round-trip per emitted line.
|
|
146
|
+
if (resolved.debug !== true) {
|
|
147
|
+
debugState.installed = true
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
if (!resolved.logFile) {
|
|
151
|
+
ctx.logger.warn('[force-compact] debug logging enabled but no log file path configured — nothing will be written')
|
|
152
|
+
debugState.installed = true
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const filePath = await expandHome(resolved.logFile)
|
|
157
|
+
if (!filePath) {
|
|
158
|
+
ctx.logger.warn('[force-compact] debug logging enabled but no log file path could be resolved — nothing will be written')
|
|
159
|
+
debugState.installed = true
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Take a SINGLE synchronous snapshot of the `debug` setting on the very first
|
|
164
|
+
// exported line (cheap `getSync`/`get`, cached forever-after). This makes the
|
|
165
|
+
// export-boundary gate race-free: `debug === false` deployments persist
|
|
166
|
+
// NOTHING even for the first line, because the snapshot resolves synchronously
|
|
167
|
+
// rather than deferring to a background read. Thereafter the cached boolean
|
|
168
|
+
// drives the gate at zero per-line cost; a mid-process `debug` flip takes
|
|
169
|
+
// effect on the next exporter reinstall.
|
|
170
|
+
let debugGate
|
|
171
|
+
const exporter = {
|
|
172
|
+
colors: false,
|
|
173
|
+
levels: { default: 3 },
|
|
174
|
+
export: (message) => {
|
|
175
|
+
// Final persistence gate lives HERE (the export boundary): a line is
|
|
176
|
+
// written iff it carries the plugin marker AND the `debug` setting is on.
|
|
177
|
+
if (!shouldInclude(message)) return
|
|
178
|
+
if (debugGate === undefined) debugGate = syncDebugSettingSnapshot(ctx)
|
|
179
|
+
if (!debugGate) return
|
|
180
|
+
void writeLine(filePath, renderLine(message))
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Bound to the current fiber; removed on stop/update/undefine.
|
|
185
|
+
ctx.logger.exporter(exporter)
|
|
186
|
+
debugState.installed = true
|
|
187
|
+
// Logged AFTER the exporter is installed so this notice lands in the file too.
|
|
188
|
+
ctx.logger.info(
|
|
189
|
+
`[force-compact] debug logging enabled — writing [force-compact] lines to ${filePath}`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Process-local install state for the debug sink: `installed` permanently stops
|
|
195
|
+
* further attempts after a settled outcome; `attempted` prevents re-entering the
|
|
196
|
+
* settings read once a decision has begun (without committing the exporter yet).
|
|
197
|
+
* Module scope, never exported; reset naturally on a fresh process.
|
|
198
|
+
*/
|
|
199
|
+
const debugState = { attempted: false, installed: false }
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Synchronously snapshot the live `debug` setting exactly once (module-level
|
|
203
|
+
* cache), resolving either through the `settings` service's synchronous read
|
|
204
|
+
* (`getSync`) or its ordinary read (returned synchronously when backed by a
|
|
205
|
+
* local store). Returns a settled boolean (never throws): missing values fall
|
|
206
|
+
* back to the composition default. Settling here at the FIRST exported line
|
|
207
|
+
* keeps the steady-state `export()` path free of any settings round-trip; a
|
|
208
|
+
* mid-process `debug` flip takes effect on the next exporter reinstall.
|
|
209
|
+
*/
|
|
210
|
+
let cachedDebug
|
|
211
|
+
function syncDebugSettingSnapshot(ctx) {
|
|
212
|
+
if (cachedDebug !== undefined) return cachedDebug
|
|
213
|
+
try {
|
|
214
|
+
const raw = ctx.get?.('settings')
|
|
215
|
+
let v
|
|
216
|
+
if (raw != null && typeof raw.getSync === 'function') {
|
|
217
|
+
v = raw.getSync(NS)?.debug
|
|
218
|
+
} else if (typeof raw?.get === 'function') {
|
|
219
|
+
v = raw.get(NS)?.debug
|
|
220
|
+
}
|
|
221
|
+
return v !== undefined ? v === true : DEFAULTS.debug
|
|
222
|
+
} catch {
|
|
223
|
+
return DEFAULTS.debug
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Keep only this plugin's own lines: the first `args` element is the log
|
|
229
|
+
* template string and must carry the `[force-compact]` marker.
|
|
230
|
+
*
|
|
231
|
+
* @param {any} message structured log record
|
|
232
|
+
* @returns {boolean}
|
|
233
|
+
*/
|
|
234
|
+
function shouldInclude(message) {
|
|
235
|
+
if (message === null || typeof message !== 'object') return false
|
|
236
|
+
const args = message.args
|
|
237
|
+
if (!Array.isArray(args) || args.length === 0) return false
|
|
238
|
+
const first = args[0]
|
|
239
|
+
return typeof first === 'string' && first.indexOf(MARKER) !== -1
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Render one message to a single prefixed line: ISO timestamp, severity tag,
|
|
244
|
+
* then the formatted arguments. Errors contribute their stack (or message);
|
|
245
|
+
* objects are compacted to JSON; everything else is stringified. Only the
|
|
246
|
+
* shallow `args` are touched — the live record itself is never cloned or
|
|
247
|
+
* recursively enumerated.
|
|
248
|
+
*
|
|
249
|
+
* @param {any} message
|
|
250
|
+
* @returns {string}
|
|
251
|
+
*/
|
|
252
|
+
function renderLine(message) {
|
|
253
|
+
// Tolerate a malformed `message`: a non-date `ts` (undefined/null/nonsense
|
|
254
|
+
// string) would otherwise make `new Date(...).toISOString()` throw on the
|
|
255
|
+
// `Invalid Date` value. Degrade to an epoch-zero timestamp so the line still
|
|
256
|
+
// renders — a diagnostic line dropping on a weird record is unacceptable.
|
|
257
|
+
let ts
|
|
258
|
+
try {
|
|
259
|
+
ts = Number.isFinite(+new Date(message.ts).getTime()) ? new Date(message.ts).toISOString() : new Date(0).toISOString()
|
|
260
|
+
} catch {
|
|
261
|
+
ts = new Date(0).toISOString()
|
|
262
|
+
}
|
|
263
|
+
const type = String(message && message.type !== undefined ? message.type : 'info').toUpperCase()
|
|
264
|
+
return `${ts} [${type}] ${formatArgs(Array.isArray(message && message.args) ? message.args : [])}`
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Format the shallow argument list into a printable string.
|
|
269
|
+
*
|
|
270
|
+
* @param {any[]} args
|
|
271
|
+
* @returns {string}
|
|
272
|
+
*/
|
|
273
|
+
function formatArgs(args) {
|
|
274
|
+
const parts = []
|
|
275
|
+
for (let i = 0; i < args.length; i++) {
|
|
276
|
+
const arg = args[i]
|
|
277
|
+
if (arg instanceof Error) parts.push(arg.stack || arg.message)
|
|
278
|
+
else if (typeof arg === 'object' && arg !== null) {
|
|
279
|
+
try {
|
|
280
|
+
parts.push(JSON.stringify(arg))
|
|
281
|
+
} catch {
|
|
282
|
+
parts.push(String(arg))
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
parts.push(String(arg))
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return parts.join(' ')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Lazily import Node's native filesystem promises, cached after the first load.
|
|
293
|
+
* Native `node:fs` is intentionally chosen over the product `fs` service because
|
|
294
|
+
* the latter is fenced by the sandbox policy to the session workspace and
|
|
295
|
+
* refuses an absolute user-home path; the native module writes the absolute
|
|
296
|
+
* target directly, independent of sandbox mode. Returns `null` if the import
|
|
297
|
+
* ever fails (extremely unlikely for a builtin), letting callers degrade.
|
|
298
|
+
*
|
|
299
|
+
* @returns {Promise<Object|null>}
|
|
300
|
+
*/
|
|
301
|
+
let nativeFsPromises
|
|
302
|
+
async function getNodeFs() {
|
|
303
|
+
if (nativeFsPromises !== undefined) return nativeFsPromises
|
|
304
|
+
try {
|
|
305
|
+
nativeFsPromises = await import('node:fs/promises')
|
|
306
|
+
} catch {
|
|
307
|
+
nativeFsPromises = null
|
|
308
|
+
}
|
|
309
|
+
return nativeFsPromises
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Append one line to the debug log using native Node `fs`. Creates the parent
|
|
314
|
+
* directory if needed, appends, and truncates to {@linkcode MAX_LOG_CHARS}
|
|
315
|
+
* keeping the tail. Fire-and-forget; swallows all errors so a diagnostic sink
|
|
316
|
+
* can never disturb a business path.
|
|
317
|
+
*
|
|
318
|
+
* @param {string} path absolute target path
|
|
319
|
+
* @param {string} line rendered line WITHOUT a trailing newline
|
|
320
|
+
*/
|
|
321
|
+
async function writeLine(path, line) {
|
|
322
|
+
try {
|
|
323
|
+
const fsp = await getNodeFs()
|
|
324
|
+
if (fsp === null) return
|
|
325
|
+
const { dirname } = await import('node:path')
|
|
326
|
+
// Ensure the containing directory exists (recursive, no-op if present).
|
|
327
|
+
await fsp.mkdir(dirname(path), { recursive: true })
|
|
328
|
+
await fsp.appendFile(path, line + '\n')
|
|
329
|
+
// Tail-cap: if the file grew past the budget, keep only the last
|
|
330
|
+
// MAX_LOG_CHARS characters so it cannot grow unbounded.
|
|
331
|
+
const stat = await fsp.stat(path).catch(() => null)
|
|
332
|
+
if (stat !== null && stat.size > MAX_LOG_CHARS) {
|
|
333
|
+
const handle = await fsp.open(path, 'r+')
|
|
334
|
+
try {
|
|
335
|
+
const buffer = Buffer.alloc(MAX_LOG_CHARS)
|
|
336
|
+
const { bytesRead } = await handle.readFile(buffer, 0, MAX_LOG_CHARS, stat.size - MAX_LOG_CHARS)
|
|
337
|
+
await handle.writeFile(Buffer.from(buffer.subarray(0, bytesRead)), 0, bytesRead, 0)
|
|
338
|
+
await handle.truncate(bytesRead)
|
|
339
|
+
} finally {
|
|
340
|
+
await handle.close()
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
} catch {
|
|
344
|
+
// Diagnostic sink must never break the requesting path.
|
|
345
|
+
}
|
|
346
|
+
}
|