tmux-ide 2.9.0-beta.19 → 2.9.0-beta.21
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/bin/cli.js +1979 -783
- package/package.json +4 -2
- package/packages/contracts/src/__tests__/daemon-wire.test.ts +32 -0
- package/packages/contracts/src/daemon-events.ts +12 -12
- package/packages/contracts/src/daemon-wire.ts +30 -0
- package/packages/daemon/dist/command-center/agent-status-watch.js +9 -2
- package/packages/daemon/dist/command-center/diagnostics.js +65 -0
- package/packages/daemon/dist/command-center/log-stream.js +9 -0
- package/packages/daemon/dist/command-center/resources/fleet-preview-route.js +22 -6
- package/packages/daemon/dist/command-center/server.js +7 -0
- package/packages/daemon/dist/doctor.js +62 -0
- package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +400 -0
- package/packages/daemon/dist/lib/app-config.js +4 -2
- package/packages/daemon/dist/lib/canonical-daemon.js +1 -0
- package/packages/daemon/dist/lib/daemon-embed.js +24 -0
- package/packages/daemon/dist/lib/daemon-provenance.js +575 -0
- package/packages/daemon/dist/lib/headless-daemon.js +1 -0
- package/packages/daemon/dist/lib/log-sanitize.js +199 -0
- package/packages/daemon/dist/lib/log.js +123 -13
- package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
- package/packages/daemon/dist/lib/soak-verdict.js +472 -0
- package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
- package/packages/daemon/dist/lib/tmux-external-interaction-observer.js +187 -39
- package/packages/daemon/dist/lib/tmux-interaction-retention.js +21 -0
- package/packages/daemon/dist/lib/workspace-promotion.js +55 -37
- package/packages/daemon/dist/terminal/mirror/session-channel.js +2 -1
- package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
- package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
- package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
- package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
- package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
- package/packages/daemon/dist/tui/mirror/runtime/application-fleet-preview.js +4 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
- package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
- package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
- package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
- package/packages/daemon/dist/tui/mirror/theme.js +4 -31
- package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
- package/packages/daemon/dist/tui/team/wait-receipts.js +112 -33
- package/packages/daemon/src/command-center/agent-status-watch.ts +8 -2
- package/packages/daemon/src/command-center/diagnostics.ts +75 -0
- package/packages/daemon/src/command-center/log-stream.ts +8 -0
- package/packages/daemon/src/command-center/resources/fleet-preview-route.ts +27 -5
- package/packages/daemon/src/command-center/server.ts +8 -0
- package/packages/daemon/src/doctor.ts +70 -0
- package/packages/daemon/src/lib/app-config.ts +11 -3
- package/packages/daemon/src/lib/canonical-daemon.ts +1 -0
- package/packages/daemon/src/lib/daemon-embed.ts +30 -0
- package/packages/daemon/src/lib/daemon-provenance.ts +769 -0
- package/packages/daemon/src/lib/fleet-preview-model.ts +1 -0
- package/packages/daemon/src/lib/headless-daemon.ts +1 -0
- package/packages/daemon/src/lib/log-sanitize.ts +248 -0
- package/packages/daemon/src/lib/log.ts +165 -14
- package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
- package/packages/daemon/src/lib/soak-verdict.ts +785 -0
- package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
- package/packages/daemon/src/lib/tmux-external-interaction-observer.ts +233 -37
- package/packages/daemon/src/lib/tmux-interaction-retention.ts +24 -0
- package/packages/daemon/src/lib/workspace-promotion.ts +74 -45
- package/packages/daemon/src/terminal/mirror/session-channel.ts +2 -1
- package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
- package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
- package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
- package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-preview.ts +4 -0
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-switcher.tsx +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
- package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +13 -3
- package/packages/daemon/src/tui/mirror/runtime/application-reference-sheet.tsx +125 -0
- package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +199 -157
- package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
- package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
- package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
- package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
- package/packages/daemon/src/tui/mirror/theme.ts +4 -39
- package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
- package/packages/daemon/src/tui/team/wait-receipts.ts +115 -40
- package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
- package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
- package/packages/tmux-bridge/src/index.ts +2 -0
- package/packages/tmux-bridge/src/runner.test.ts +41 -0
- package/packages/tmux-bridge/src/runner.ts +41 -2
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PURE — credential redaction, byte budgets and reserved-metadata protection
|
|
3
|
+
* for the shared structured logger (`log.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Every record the daemon emits passes through `sanitizeLogPayload` before it
|
|
6
|
+
* reaches the ring buffer, subscribers or the wire. The rules are deliberately
|
|
7
|
+
* conservative: a value that looks like a credential is replaced, oversized
|
|
8
|
+
* payloads are truncated with an explicit marker, and caller data can never
|
|
9
|
+
* overwrite the identity fields (`ts`, `level`, `component`, `msg`, `pid`,
|
|
10
|
+
* `instanceId`, `version`) that incident triage relies on.
|
|
11
|
+
*/
|
|
12
|
+
export const REDACTED = "[redacted]";
|
|
13
|
+
/** Wire keys owned by the logger; caller payloads with these keys are renamed. */
|
|
14
|
+
export const RESERVED_LOG_KEYS = Object.freeze([
|
|
15
|
+
"ts",
|
|
16
|
+
"level",
|
|
17
|
+
"component",
|
|
18
|
+
"msg",
|
|
19
|
+
"pid",
|
|
20
|
+
"instanceId",
|
|
21
|
+
"version",
|
|
22
|
+
]);
|
|
23
|
+
const RESERVED = new Set(RESERVED_LOG_KEYS);
|
|
24
|
+
/** Prefix applied to caller keys that collide with reserved metadata. */
|
|
25
|
+
export const RESERVED_COLLISION_PREFIX = "payload_";
|
|
26
|
+
export const DEFAULT_LOG_BUDGET = Object.freeze({
|
|
27
|
+
maxDataBytes: 8 * 1024,
|
|
28
|
+
maxStringBytes: 2 * 1024,
|
|
29
|
+
maxMessageBytes: 4 * 1024,
|
|
30
|
+
maxDepth: 6,
|
|
31
|
+
maxContainerSize: 64,
|
|
32
|
+
});
|
|
33
|
+
/** Total serialized bytes the in-memory ring may retain across all entries. */
|
|
34
|
+
export const DEFAULT_LOG_RING_BYTES = 512 * 1024;
|
|
35
|
+
/**
|
|
36
|
+
* Keys whose values are credentials regardless of shape. Matched on the key
|
|
37
|
+
* with separators removed and case folded so `auth_token`, `AuthToken`,
|
|
38
|
+
* `X-API-Key` and `authorization` all hit.
|
|
39
|
+
*/
|
|
40
|
+
const SECRET_KEY = /(token|secret|password|passwd|authorization|cookie|apikey|credential|capability|privatekey|bearer|ticket|lease)/u;
|
|
41
|
+
export function isSecretKey(key) {
|
|
42
|
+
return SECRET_KEY.test(key.toLowerCase().replace(/[^a-z0-9]/gu, ""));
|
|
43
|
+
}
|
|
44
|
+
const BEARER = /\bBearer\s+[^\s"',;]+/giu;
|
|
45
|
+
const BASIC = /\bBasic\s+[A-Za-z0-9+/=]{8,}/gu;
|
|
46
|
+
const URL_QUERY_SECRET = /([?&](?:[A-Za-z0-9_-]*(?:token|ticket|lease|capability|password|secret|auth|key|signature)[A-Za-z0-9_-]*)=)[^&#\s"']*/giu;
|
|
47
|
+
const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)([^/\s@"']+)@/giu;
|
|
48
|
+
// Values already handled by the header/URL passes (Bearer/Basic schemes, an
|
|
49
|
+
// earlier redaction marker) are skipped so a second pass cannot eat the
|
|
50
|
+
// surrounding text.
|
|
51
|
+
const KEY_VALUE_SECRET = /\b((?:auth[-_]?token|access[-_]?token|refresh[-_]?token|authorization|capability|password|passwd|secret|token|ticket|lease|api[-_]?key)\s*[:=]\s*["']?)(?!Bearer\b|Basic\b|\[redacted\])[^\s,;"'}]+/giu;
|
|
52
|
+
/** Redact credential-shaped substrings inside free text (URLs, headers, k=v). */
|
|
53
|
+
export function redactText(text, secrets = EMPTY) {
|
|
54
|
+
let out = text;
|
|
55
|
+
for (const secret of secrets)
|
|
56
|
+
if (secret && out.includes(secret))
|
|
57
|
+
out = out.split(secret).join(REDACTED);
|
|
58
|
+
return out
|
|
59
|
+
.replace(BEARER, `Bearer ${REDACTED}`)
|
|
60
|
+
.replace(BASIC, `Basic ${REDACTED}`)
|
|
61
|
+
.replace(URL_USERINFO, `$1${REDACTED}@`)
|
|
62
|
+
.replace(URL_QUERY_SECRET, `$1${REDACTED}`)
|
|
63
|
+
.replace(KEY_VALUE_SECRET, `$1${REDACTED}`);
|
|
64
|
+
}
|
|
65
|
+
const EMPTY = new Set();
|
|
66
|
+
function byteLength(text) {
|
|
67
|
+
return Buffer.byteLength(text, "utf8");
|
|
68
|
+
}
|
|
69
|
+
function truncateString(text, maxBytes) {
|
|
70
|
+
if (byteLength(text) <= maxBytes)
|
|
71
|
+
return text;
|
|
72
|
+
// Trim on code points so a multi-byte character is never split.
|
|
73
|
+
let kept = "";
|
|
74
|
+
let used = 0;
|
|
75
|
+
for (const char of text) {
|
|
76
|
+
const size = byteLength(char);
|
|
77
|
+
if (used + size > maxBytes)
|
|
78
|
+
break;
|
|
79
|
+
kept += char;
|
|
80
|
+
used += size;
|
|
81
|
+
}
|
|
82
|
+
return `${kept}…[truncated ${byteLength(text) - used} bytes]`;
|
|
83
|
+
}
|
|
84
|
+
function describeError(error) {
|
|
85
|
+
const out = { name: error.name, message: error.message };
|
|
86
|
+
const code = error.code;
|
|
87
|
+
if (code !== undefined)
|
|
88
|
+
out.code = code;
|
|
89
|
+
if (error.stack)
|
|
90
|
+
out.stack = error.stack;
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
function redactValue(value, depth, options, seen) {
|
|
94
|
+
const { budget, secrets } = options;
|
|
95
|
+
if (value === null || value === undefined)
|
|
96
|
+
return value;
|
|
97
|
+
if (typeof value === "string")
|
|
98
|
+
return truncateString(redactText(value, secrets), budget.maxStringBytes);
|
|
99
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
100
|
+
return value;
|
|
101
|
+
if (typeof value === "bigint")
|
|
102
|
+
return value.toString();
|
|
103
|
+
if (typeof value === "symbol" || typeof value === "function")
|
|
104
|
+
return `[${typeof value}]`;
|
|
105
|
+
if (value instanceof Date)
|
|
106
|
+
return value.toISOString();
|
|
107
|
+
if (value instanceof Error)
|
|
108
|
+
return redactValue(describeError(value), depth, options, seen);
|
|
109
|
+
if (Buffer.isBuffer(value) || ArrayBuffer.isView(value))
|
|
110
|
+
return `[binary ${value.byteLength} bytes]`;
|
|
111
|
+
if (typeof value !== "object")
|
|
112
|
+
return String(value);
|
|
113
|
+
if (seen.has(value))
|
|
114
|
+
return "[circular]";
|
|
115
|
+
if (depth >= budget.maxDepth)
|
|
116
|
+
return "[depth exceeded]";
|
|
117
|
+
seen.add(value);
|
|
118
|
+
try {
|
|
119
|
+
if (Array.isArray(value)) {
|
|
120
|
+
const items = value
|
|
121
|
+
.slice(0, budget.maxContainerSize)
|
|
122
|
+
.map((item) => redactValue(item, depth + 1, options, seen));
|
|
123
|
+
if (value.length > budget.maxContainerSize)
|
|
124
|
+
items.push(`[+${value.length - budget.maxContainerSize} items]`);
|
|
125
|
+
return items;
|
|
126
|
+
}
|
|
127
|
+
if (value instanceof Map)
|
|
128
|
+
return redactValue(Object.fromEntries(value), depth, options, seen);
|
|
129
|
+
if (value instanceof Set)
|
|
130
|
+
return redactValue([...value], depth, options, seen);
|
|
131
|
+
const out = {};
|
|
132
|
+
const keys = Object.keys(value);
|
|
133
|
+
for (const key of keys.slice(0, budget.maxContainerSize)) {
|
|
134
|
+
const child = value[key];
|
|
135
|
+
out[key] =
|
|
136
|
+
isSecretKey(key) && child != null ? REDACTED : redactValue(child, depth + 1, options, seen);
|
|
137
|
+
}
|
|
138
|
+
if (keys.length > budget.maxContainerSize)
|
|
139
|
+
out["[+keys]"] = keys.length - budget.maxContainerSize;
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
seen.delete(value);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Redact one arbitrary value (strings, nested objects, errors) under the default budget. */
|
|
147
|
+
export function redactLogValue(value, secrets = EMPTY, budget = DEFAULT_LOG_BUDGET) {
|
|
148
|
+
return redactValue(value, 0, { budget, secrets }, new WeakSet());
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Redact, protect reserved keys and bound a caller payload. Deterministic:
|
|
152
|
+
* the same input always yields the same output, so tests can assert exact
|
|
153
|
+
* shapes.
|
|
154
|
+
*/
|
|
155
|
+
export function sanitizeLogPayload(data, options = {}) {
|
|
156
|
+
if (!data)
|
|
157
|
+
return { data: undefined, bytes: 0, truncated: false, renamedKeys: [] };
|
|
158
|
+
const budget = options.budget ?? DEFAULT_LOG_BUDGET;
|
|
159
|
+
const secrets = options.secrets ?? EMPTY;
|
|
160
|
+
const redacted = redactValue(data, 0, { budget, secrets }, new WeakSet());
|
|
161
|
+
const renamedKeys = [];
|
|
162
|
+
const protectedData = {};
|
|
163
|
+
for (const [key, value] of Object.entries(redacted)) {
|
|
164
|
+
if (RESERVED.has(key)) {
|
|
165
|
+
renamedKeys.push(key);
|
|
166
|
+
protectedData[`${RESERVED_COLLISION_PREFIX}${key}`] = value;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
protectedData[key] = value;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
let serialized = safeStringify(protectedData);
|
|
173
|
+
let bytes = byteLength(serialized);
|
|
174
|
+
if (bytes <= budget.maxDataBytes) {
|
|
175
|
+
return { data: protectedData, bytes, truncated: false, renamedKeys };
|
|
176
|
+
}
|
|
177
|
+
// Over budget even after per-string limits: keep the key list so the record
|
|
178
|
+
// stays diagnosable, and say how much was dropped.
|
|
179
|
+
const summary = {
|
|
180
|
+
truncated: true,
|
|
181
|
+
droppedBytes: bytes,
|
|
182
|
+
keys: Object.keys(protectedData).slice(0, budget.maxContainerSize),
|
|
183
|
+
};
|
|
184
|
+
serialized = safeStringify(summary);
|
|
185
|
+
bytes = byteLength(serialized);
|
|
186
|
+
return { data: summary, bytes, truncated: true, renamedKeys };
|
|
187
|
+
}
|
|
188
|
+
/** Bound a log message: redact and truncate to the message budget. */
|
|
189
|
+
export function sanitizeLogMessage(message, secrets = EMPTY, budget = DEFAULT_LOG_BUDGET) {
|
|
190
|
+
return truncateString(redactText(String(message), secrets), budget.maxMessageBytes);
|
|
191
|
+
}
|
|
192
|
+
function safeStringify(value) {
|
|
193
|
+
try {
|
|
194
|
+
return JSON.stringify(value) ?? "null";
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return '"[unserializable]"';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -1,16 +1,67 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Minimal structured logger for tmux-ide daemon processes.
|
|
3
|
+
*
|
|
4
|
+
* Every record is sanitized centrally (`log-sanitize.ts`): credential-shaped
|
|
5
|
+
* keys and substrings are redacted, payloads are bounded per record and for
|
|
6
|
+
* the in-memory ring, and the identity metadata a triage relies on (`ts`,
|
|
7
|
+
* `level`, `component`, `msg`, `pid`, `instanceId`, `version`) can never be
|
|
8
|
+
* overwritten by caller data. Stream write failures (closed pipe, full disk)
|
|
9
|
+
* degrade to ring-only logging with one explicit warning; they never crash
|
|
10
|
+
* the process.
|
|
11
|
+
*/
|
|
12
|
+
import { DEFAULT_LOG_BUDGET, DEFAULT_LOG_RING_BYTES, sanitizeLogMessage, sanitizeLogPayload, } from "./log-sanitize.js";
|
|
2
13
|
const LEVEL_RANK = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
3
14
|
let minLevel = process.env.LOG_LEVEL ?? "info";
|
|
4
15
|
export function setLogLevel(level) {
|
|
5
16
|
minLevel = level;
|
|
6
17
|
}
|
|
18
|
+
let identity = {};
|
|
19
|
+
const secrets = new Set();
|
|
20
|
+
/** Stamp reserved identity metadata onto every subsequent record. */
|
|
21
|
+
export function setLogIdentity(next) {
|
|
22
|
+
identity = next ? { ...next } : {};
|
|
23
|
+
}
|
|
24
|
+
export function getLogIdentity() {
|
|
25
|
+
return { ...identity };
|
|
26
|
+
}
|
|
27
|
+
/** Register a credential literal to redact wherever it appears in a record. */
|
|
28
|
+
export function registerLogSecret(secret) {
|
|
29
|
+
if (typeof secret === "string" && secret.length >= 8)
|
|
30
|
+
secrets.add(secret);
|
|
31
|
+
}
|
|
32
|
+
/** @internal test seam */
|
|
33
|
+
export function _resetLogStateForTests() {
|
|
34
|
+
identity = {};
|
|
35
|
+
secrets.clear();
|
|
36
|
+
logBuffer.length = 0;
|
|
37
|
+
ringBytes = 0;
|
|
38
|
+
streamState.stdout = { failed: false, listening: false };
|
|
39
|
+
streamState.stderr = { failed: false, listening: false };
|
|
40
|
+
budget = DEFAULT_LOG_BUDGET;
|
|
41
|
+
ringBudgetBytes = DEFAULT_LOG_RING_BYTES;
|
|
42
|
+
}
|
|
43
|
+
/** @internal test seam — shrink budgets to make bounds observable. */
|
|
44
|
+
export function _setLogBudgetForTests(next) {
|
|
45
|
+
const { ringBytes, ...rest } = next;
|
|
46
|
+
budget = { ...DEFAULT_LOG_BUDGET, ...rest };
|
|
47
|
+
if (ringBytes !== undefined)
|
|
48
|
+
ringBudgetBytes = ringBytes;
|
|
49
|
+
}
|
|
7
50
|
const LOG_BUFFER_SIZE = 1_000;
|
|
8
51
|
const logBuffer = [];
|
|
52
|
+
const entryBytes = new WeakMap();
|
|
53
|
+
let ringBytes = 0;
|
|
54
|
+
let budget = DEFAULT_LOG_BUDGET;
|
|
55
|
+
let ringBudgetBytes = DEFAULT_LOG_RING_BYTES;
|
|
9
56
|
const subscribers = new Set();
|
|
10
57
|
/** Snapshot the current ring buffer; useful for SSE backfill. */
|
|
11
58
|
export function getLogBuffer() {
|
|
12
59
|
return logBuffer.slice();
|
|
13
60
|
}
|
|
61
|
+
/** Ring occupancy for diagnostics: entry count and retained serialized bytes. */
|
|
62
|
+
export function getLogBufferStats() {
|
|
63
|
+
return { entries: logBuffer.length, bytes: ringBytes, budgetBytes: ringBudgetBytes };
|
|
64
|
+
}
|
|
14
65
|
/**
|
|
15
66
|
* Subscribe to live log entries. Returns an unsubscribe function. The
|
|
16
67
|
* handler runs synchronously inside the writer — keep it cheap; throw
|
|
@@ -23,20 +74,75 @@ export function subscribeLogs(handler) {
|
|
|
23
74
|
subscribers.delete(handler);
|
|
24
75
|
};
|
|
25
76
|
}
|
|
77
|
+
const streamState = {
|
|
78
|
+
stdout: { failed: false, listening: false },
|
|
79
|
+
stderr: { failed: false, listening: false },
|
|
80
|
+
};
|
|
81
|
+
/** Which process streams the logger has given up on (diagnostics only). */
|
|
82
|
+
export function getLogStreamFailures() {
|
|
83
|
+
return Object.keys(streamState).filter((name) => streamState[name].failed);
|
|
84
|
+
}
|
|
85
|
+
function markStreamFailed(name, error) {
|
|
86
|
+
if (streamState[name].failed)
|
|
87
|
+
return;
|
|
88
|
+
streamState[name].failed = true;
|
|
89
|
+
const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
90
|
+
const warning = `[log.ts] ${name} write failed (${detail}); further records are retained in memory only\n`;
|
|
91
|
+
const other = name === "stdout" ? "stderr" : "stdout";
|
|
92
|
+
if (!streamState[other].failed) {
|
|
93
|
+
try {
|
|
94
|
+
process[other].write(warning);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
streamState[other].failed = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function writeToStream(name, line) {
|
|
102
|
+
const state = streamState[name];
|
|
103
|
+
if (state.failed)
|
|
104
|
+
return;
|
|
105
|
+
const stream = process[name];
|
|
106
|
+
if (!state.listening) {
|
|
107
|
+
state.listening = true;
|
|
108
|
+
// A closed pipe (EPIPE) or full disk (ENOSPC) surfaces asynchronously as
|
|
109
|
+
// an 'error' event; without a listener Node raises it as an uncaught
|
|
110
|
+
// exception and the daemon dies mid-triage.
|
|
111
|
+
stream.on("error", (error) => markStreamFailed(name, error));
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
stream.write(line);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
markStreamFailed(name, error);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
26
120
|
function writeStructuredLog(level, component, message, data) {
|
|
27
121
|
if (LEVEL_RANK[level] < LEVEL_RANK[minLevel])
|
|
28
122
|
return;
|
|
123
|
+
const payload = sanitizeLogPayload(data, { secrets, budget });
|
|
29
124
|
const entry = {
|
|
30
125
|
ts: new Date().toISOString(),
|
|
31
126
|
level,
|
|
32
|
-
component,
|
|
33
|
-
msg: message,
|
|
34
|
-
|
|
127
|
+
component: sanitizeLogMessage(String(component), secrets, budget).slice(0, 128),
|
|
128
|
+
msg: sanitizeLogMessage(message, secrets, budget),
|
|
129
|
+
pid: process.pid,
|
|
130
|
+
...(identity.instanceId ? { instanceId: identity.instanceId } : {}),
|
|
131
|
+
...(identity.version ? { version: identity.version } : {}),
|
|
132
|
+
...(payload.data ? { data: payload.data } : {}),
|
|
35
133
|
};
|
|
36
|
-
|
|
134
|
+
const size = payload.bytes + Buffer.byteLength(entry.msg, "utf8") + 96;
|
|
135
|
+
entryBytes.set(entry, size);
|
|
136
|
+
// Push to ring + drop oldest while over either budget.
|
|
37
137
|
logBuffer.push(entry);
|
|
38
|
-
|
|
39
|
-
|
|
138
|
+
ringBytes += size;
|
|
139
|
+
while (logBuffer.length > 0 &&
|
|
140
|
+
(logBuffer.length > LOG_BUFFER_SIZE || ringBytes > ringBudgetBytes)) {
|
|
141
|
+
const evicted = logBuffer.shift();
|
|
142
|
+
ringBytes -= entryBytes.get(evicted) ?? 0;
|
|
143
|
+
if (logBuffer.length === 0)
|
|
144
|
+
ringBytes = 0;
|
|
145
|
+
}
|
|
40
146
|
// Fan out to subscribers; isolate failures.
|
|
41
147
|
for (const sub of subscribers) {
|
|
42
148
|
try {
|
|
@@ -44,21 +150,25 @@ function writeStructuredLog(level, component, message, data) {
|
|
|
44
150
|
}
|
|
45
151
|
catch (err) {
|
|
46
152
|
// Avoid recursion via the logger; write directly to stderr.
|
|
47
|
-
|
|
153
|
+
writeToStream("stderr", `[log.ts] subscriber threw: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
48
154
|
}
|
|
49
155
|
}
|
|
50
156
|
// Stream form for humans tailing the process. The data field is
|
|
51
|
-
// serialized inline (matches the previous wire shape)
|
|
157
|
+
// serialized inline (matches the previous wire shape); reserved metadata
|
|
158
|
+
// is written last so a caller payload can never shadow it.
|
|
52
159
|
const wire = {
|
|
160
|
+
...(entry.data ?? {}),
|
|
53
161
|
ts: entry.ts,
|
|
54
162
|
level: entry.level,
|
|
55
163
|
component: entry.component,
|
|
56
164
|
msg: entry.msg,
|
|
165
|
+
pid: entry.pid,
|
|
166
|
+
...(entry.instanceId ? { instanceId: entry.instanceId } : {}),
|
|
167
|
+
...(entry.version ? { version: entry.version } : {}),
|
|
57
168
|
};
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
out.write(JSON.stringify(wire) + "\n");
|
|
169
|
+
if (payload.renamedKeys.length > 0)
|
|
170
|
+
wire.reservedKeysRenamed = payload.renamedKeys;
|
|
171
|
+
writeToStream(level === "error" ? "stderr" : "stdout", JSON.stringify(wire) + "\n");
|
|
62
172
|
}
|
|
63
173
|
export const logger = {
|
|
64
174
|
debug: (component, msg, data) => writeStructuredLog("debug", component, msg, data),
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** Pure, allowlisted evidence projection. Never retain response bodies or errors. */
|
|
2
|
+
export const DIAGNOSTICS_MAX_BYTES = 16_384;
|
|
3
|
+
export const RESOURCE_KEYS = [
|
|
4
|
+
"Timeout",
|
|
5
|
+
"Immediate",
|
|
6
|
+
"TCPServerWrap",
|
|
7
|
+
"TCPSocketWrap",
|
|
8
|
+
"PipeWrap",
|
|
9
|
+
"ProcessWrap",
|
|
10
|
+
"FSEventWrap",
|
|
11
|
+
"other",
|
|
12
|
+
];
|
|
13
|
+
export const MEMORY_KEYS = ["rss", "heapTotal", "heapUsed", "external", "arrayBuffers"];
|
|
14
|
+
const object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
15
|
+
const number = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
16
|
+
const text = (v) => typeof v === "string" && v.length > 0 && v.length <= 256;
|
|
17
|
+
function numeric(v, keys, integer = false) {
|
|
18
|
+
if (!object(v) || !keys.every((k) => number(v[k]) && (!integer || Number.isSafeInteger(v[k]))))
|
|
19
|
+
return null;
|
|
20
|
+
return Object.fromEntries(keys.map((k) => [k, v[k]]));
|
|
21
|
+
}
|
|
22
|
+
export function parseSoakDiagnostics(body, status, expected) {
|
|
23
|
+
const fail = (status) => ({
|
|
24
|
+
status,
|
|
25
|
+
sample: null,
|
|
26
|
+
});
|
|
27
|
+
if (status === 404)
|
|
28
|
+
return fail("unsupported-endpoint");
|
|
29
|
+
if (status !== 200)
|
|
30
|
+
return fail("http-error");
|
|
31
|
+
if (body === null || body === "")
|
|
32
|
+
return fail("missing");
|
|
33
|
+
if (new TextEncoder().encode(body).length > DIAGNOSTICS_MAX_BYTES)
|
|
34
|
+
return fail("malformed");
|
|
35
|
+
let raw;
|
|
36
|
+
try {
|
|
37
|
+
raw = JSON.parse(body);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return fail("malformed");
|
|
41
|
+
}
|
|
42
|
+
if (!object(raw) || !object(raw.daemon))
|
|
43
|
+
return fail("malformed");
|
|
44
|
+
const d = raw.daemon;
|
|
45
|
+
if (!Number.isSafeInteger(d.protocolVersion) ||
|
|
46
|
+
!number(d.protocolVersion) ||
|
|
47
|
+
!text(d.productVersion) ||
|
|
48
|
+
!text(d.instanceId) ||
|
|
49
|
+
!text(d.startedAt) ||
|
|
50
|
+
!Number.isFinite(Date.parse(d.startedAt)) ||
|
|
51
|
+
(d.environmentId !== undefined && !text(d.environmentId)) ||
|
|
52
|
+
!Number.isSafeInteger(raw.pid) ||
|
|
53
|
+
!number(raw.pid) ||
|
|
54
|
+
raw.pid === 0)
|
|
55
|
+
return fail("malformed");
|
|
56
|
+
if (["protocolVersion", "productVersion", "instanceId", "startedAt", "environmentId"].some((k) => d[k] !== expected[k]) ||
|
|
57
|
+
raw.pid !== expected.pid)
|
|
58
|
+
return fail("identity-mismatch");
|
|
59
|
+
const memory = numeric(raw.memory, MEMORY_KEYS, true);
|
|
60
|
+
const cpu = numeric(raw.cpu, ["user", "system"], true);
|
|
61
|
+
const eventLoop = numeric(raw.eventLoop, ["idle", "active", "utilization"]);
|
|
62
|
+
const activeResources = raw.activeResources === null ? null : numeric(raw.activeResources, RESOURCE_KEYS, true);
|
|
63
|
+
if (!memory ||
|
|
64
|
+
!cpu ||
|
|
65
|
+
!eventLoop ||
|
|
66
|
+
eventLoop.utilization > 1 ||
|
|
67
|
+
!number(raw.uptimeMs) ||
|
|
68
|
+
!number(raw.sampledAtMs) ||
|
|
69
|
+
(raw.activeResources !== null && !activeResources))
|
|
70
|
+
return fail("malformed");
|
|
71
|
+
return {
|
|
72
|
+
status: "ok",
|
|
73
|
+
sample: {
|
|
74
|
+
daemon: {
|
|
75
|
+
protocolVersion: d.protocolVersion,
|
|
76
|
+
productVersion: d.productVersion,
|
|
77
|
+
instanceId: d.instanceId,
|
|
78
|
+
startedAt: d.startedAt,
|
|
79
|
+
...(d.environmentId !== undefined ? { environmentId: d.environmentId } : {}),
|
|
80
|
+
},
|
|
81
|
+
pid: raw.pid,
|
|
82
|
+
uptimeMs: raw.uptimeMs,
|
|
83
|
+
sampledAtMs: raw.sampledAtMs,
|
|
84
|
+
memory,
|
|
85
|
+
cpu,
|
|
86
|
+
eventLoop,
|
|
87
|
+
activeResources,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** Uptime is monotonic; wall-clock jumps do not change interval rates. */
|
|
92
|
+
export function diagnosticsDelta(previous, current) {
|
|
93
|
+
if (!previous)
|
|
94
|
+
return { status: "missing-baseline" };
|
|
95
|
+
if (previous.pid !== current.pid ||
|
|
96
|
+
["protocolVersion", "productVersion", "instanceId", "startedAt", "environmentId"].some((key) => previous.daemon[key] !==
|
|
97
|
+
current.daemon[key]))
|
|
98
|
+
return { status: "identity-mismatch" };
|
|
99
|
+
const intervalMs = current.uptimeMs - previous.uptimeMs;
|
|
100
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0)
|
|
101
|
+
return { status: "invalid-interval" };
|
|
102
|
+
const cpuUserMicros = current.cpu.user - previous.cpu.user;
|
|
103
|
+
const cpuSystemMicros = current.cpu.system - previous.cpu.system;
|
|
104
|
+
const eventLoopIdleMs = current.eventLoop.idle - previous.eventLoop.idle;
|
|
105
|
+
const eventLoopActiveMs = current.eventLoop.active - previous.eventLoop.active;
|
|
106
|
+
if (![cpuUserMicros, cpuSystemMicros, eventLoopIdleMs, eventLoopActiveMs].every(number))
|
|
107
|
+
return { status: "counter-regression" };
|
|
108
|
+
const cpuPercent = (cpuUserMicros + cpuSystemMicros) / (intervalMs * 10);
|
|
109
|
+
if (!Number.isFinite(cpuPercent))
|
|
110
|
+
return { status: "invalid-interval" };
|
|
111
|
+
const eventTotal = eventLoopIdleMs + eventLoopActiveMs;
|
|
112
|
+
if (!Number.isFinite(eventTotal))
|
|
113
|
+
return { status: "invalid-interval" };
|
|
114
|
+
return {
|
|
115
|
+
status: "ok",
|
|
116
|
+
intervalMs,
|
|
117
|
+
cpuUserMicros,
|
|
118
|
+
cpuSystemMicros,
|
|
119
|
+
cpuPercent,
|
|
120
|
+
eventLoopIdleMs,
|
|
121
|
+
eventLoopActiveMs,
|
|
122
|
+
eventLoopUtilization: eventTotal === 0 ? null : eventLoopActiveMs / eventTotal,
|
|
123
|
+
};
|
|
124
|
+
}
|