Files
NetMesh/infrastructure/ai/harness/terminalMonitorGuard.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

209 lines
7.5 KiB
TypeScript

import { compressVerboseText } from '../requestPayloadCompression';
const MONITOR_LINE_MAX_CHARS = 500;
const MONITOR_BATCH_MAX_CHARS = 3_000;
const MONITOR_BURST = 10;
const MONITOR_REFILL_MS = 2_000;
const MONITOR_OVERLOAD_STOP_MS = 30_000;
interface MonitorState {
tokens: number;
lastRefillAt: number;
overloadedAt?: number;
lastSuppressedAt?: number;
suppressedCount: number;
}
export type MonitorGuardResult =
| { action: 'deliver'; content: string; suppressedCount: number; sourceTruncated: boolean }
| { action: 'suppress'; suppressedCount: number }
| { action: 'stop'; suppressedCount: number };
export class TerminalMonitorGuard {
private readonly states = new Map<string, MonitorState>();
private readonly now: () => number;
constructor(options: { now?: () => number } = {}) {
this.now = options.now ?? Date.now;
}
process(key: string, output: string): MonitorGuardResult {
const now = this.now();
const state = this.states.get(key) ?? {
tokens: MONITOR_BURST,
lastRefillAt: now,
suppressedCount: 0,
};
if (state.lastSuppressedAt != null && now - state.lastSuppressedAt > MONITOR_REFILL_MS * 2) {
state.overloadedAt = undefined;
state.lastSuppressedAt = undefined;
state.suppressedCount = 0;
}
if (state.overloadedAt != null && now - state.overloadedAt >= MONITOR_OVERLOAD_STOP_MS) {
this.states.delete(key);
return { action: 'stop', suppressedCount: state.suppressedCount + 1 };
}
const refill = Math.floor((now - state.lastRefillAt) / MONITOR_REFILL_MS);
if (refill > 0) {
state.tokens = Math.min(MONITOR_BURST, state.tokens + refill);
state.lastRefillAt += refill * MONITOR_REFILL_MS;
}
if (state.tokens <= 0) {
state.suppressedCount += 1;
state.overloadedAt ??= now;
state.lastSuppressedAt = now;
this.states.set(key, state);
return { action: 'suppress', suppressedCount: state.suppressedCount };
}
state.tokens -= 1;
const suppressedCount = state.suppressedCount;
state.suppressedCount = 0;
this.states.set(key, state);
const prefix = suppressedCount > 0 ? `[${suppressedCount} monitor batches suppressed]\n` : '';
const fitted = fitMonitorBatch(`${prefix}${output}`);
return {
action: 'deliver',
content: fitted.content,
suppressedCount,
sourceTruncated: fitted.sourceTruncated,
};
}
clear(key: string): void {
this.states.delete(key);
}
clearPrefix(prefix: string): void {
for (const key of this.states.keys()) {
if (key.startsWith(prefix)) this.states.delete(key);
}
}
}
export function isStreamingMonitorCommand(command: unknown): boolean {
if (typeof command !== 'string') return false;
return command.split(/[;&|]+/).some((rawSegment) => {
const tokens = unwrapMonitorCommandPrefixes(tokenizeCommandSegment(rawSegment));
if (tokens.length === 0) return false;
if (tokens[0]?.toLowerCase() === 'watch') return true;
const argsStart = findMonitorArgsStart(tokens);
if (argsStart < 0) return false;
return tokens.slice(argsStart).some(arg => /^--follow(?:=.+)?$/i.test(arg) || /^-[^-\s]*[fF]/.test(arg));
});
}
function findMonitorArgsStart(tokens: string[]): number {
const command = tokens[0]?.toLowerCase();
if (command === 'tail' || command === 'journalctl') return 1;
if (command === 'kubectl') {
const logsIndex = skipCommandOptions(tokens, 1, new Set([
'-n', '--namespace', '--context', '--kubeconfig', '--cluster', '--user',
'--request-timeout', '-s', '--server', '--token', '--as', '--as-group',
'--cache-dir', '--certificate-authority', '--client-certificate', '--client-key',
'--tls-server-name',
]));
return tokens[logsIndex]?.toLowerCase() === 'logs' ? logsIndex + 1 : -1;
}
if (command === 'docker') {
const subcommandIndex = skipCommandOptions(tokens, 1, new Set([
'--config', '-c', '--context', '-H', '--host', '-l', '--log-level',
]));
const subcommand = tokens[subcommandIndex]?.toLowerCase();
if (subcommand === 'logs') return subcommandIndex + 1;
if (subcommand !== 'compose') return -1;
const logsIndex = skipCommandOptions(tokens, subcommandIndex + 1, new Set([
'-f', '--file', '-p', '--project-name', '--profile', '--project-directory',
'--env-file', '--parallel', '--progress', '--ansi',
]));
return tokens[logsIndex]?.toLowerCase() === 'logs' ? logsIndex + 1 : -1;
}
return -1;
}
function skipCommandOptions(tokens: string[], start: number, optionsWithValues: ReadonlySet<string>): number {
let index = start;
while (tokens[index]?.startsWith('-')) {
const option = tokens[index]!;
index += 1;
if (option === '--') break;
const optionName = canonicalOptionName(option);
if (optionsWithValues.has(optionName) && !option.includes('=') && index < tokens.length) index += 1;
}
return index;
}
function tokenizeCommandSegment(segment: string): string[] {
return segment.trim().match(/(?:[^\s"'\\]+|"(?:\\.|[^"])*"|'[^']*')+/g) ?? [];
}
function unwrapMonitorCommandPrefixes(input: string[]): string[] {
const tokens = [...input];
for (let pass = 0; pass < 8 && tokens.length > 0; pass += 1) {
const wrapper = tokens[0]?.toLowerCase();
if (wrapper === 'sudo') {
tokens.shift();
consumeWrapperOptions(tokens, new Set([
'-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt',
'-C', '--close-from', '-R', '--chroot', '-D', '--chdir', '-T', '--command-timeout',
'-r', '--role', '-t', '--type',
]));
continue;
}
if (wrapper === 'env') {
tokens.shift();
consumeWrapperOptions(tokens, new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string']));
while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens.shift();
continue;
}
if (wrapper === 'stdbuf') {
tokens.shift();
consumeWrapperOptions(tokens, new Set(['-i', '--input', '-o', '--output', '-e', '--error']));
continue;
}
if (wrapper === 'timeout') {
tokens.shift();
consumeWrapperOptions(tokens, new Set(['-k', '--kill-after', '-s', '--signal']));
if (tokens.length > 0) tokens.shift();
continue;
}
break;
}
return tokens;
}
function consumeWrapperOptions(tokens: string[], optionsWithValues: ReadonlySet<string>): void {
while (tokens[0]?.startsWith('-')) {
const option = tokens.shift()!;
if (option === '--') break;
const optionName = canonicalOptionName(option);
if (optionsWithValues.has(optionName) && !option.includes('=') && tokens.length > 0) tokens.shift();
}
}
function canonicalOptionName(option: string): string {
const optionName = option.split('=', 1)[0]!;
return optionName.startsWith('--') ? optionName.toLowerCase() : optionName;
}
function fitMonitorBatch(output: string): { content: string; sourceTruncated: boolean } {
const normalized = compressVerboseText(output);
let sourceTruncated = false;
const lines = normalized.split('\n').map(line => {
if (line.length <= MONITOR_LINE_MAX_CHARS) return line;
sourceTruncated = true;
return `${line.slice(0, MONITOR_LINE_MAX_CHARS - 28)}[... line shortened ...]`;
});
const content = lines.join('\n');
if (content.length <= MONITOR_BATCH_MAX_CHARS) return { content, sourceTruncated };
const marker = '\n[... monitor batch shortened ...]';
return {
content: `${content.slice(0, MONITOR_BATCH_MAX_CHARS - marker.length)}${marker}`,
sourceTruncated: true,
};
}
export const globalTerminalMonitorGuard = new TerminalMonitorGuard();