CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
RemoteInputWatcher.ts376 linesDownload Raw Back to remoteInput
1/**2 * @license3 * Copyright 2025 Qwen Team4 * SPDX-License-Identifier: Apache-2.05 */6 7import { createHash } from 'node:crypto';8import {9  closeSync,10  createReadStream,11  openSync,12  readSync,13  statSync,14  unwatchFile,15  watchFile,16} from 'node:fs';17import { createInterface } from 'node:readline';18import { createDebugLogger } from '@qwen-code/qwen-code-core';19 20const debugLogger = createDebugLogger('REMOTE_INPUT');21 22/**23 * JSONL command shapes written by an external process (IDE extension,24 * web frontend, automation script) into the file passed to --input-file.25 *26 * - `submit`: enqueue a user message that the TUI processes as if typed27 *   into the prompt.28 * - `confirmation_response`: reply to a pending tool-permission29 *   `control_request` previously emitted on the dual-output channel.30 */31export type RemoteInputCommand =32  | { type: 'submit'; text: string }33  | { type: 'confirmation_response'; request_id: string; allowed: boolean };34 35/**36 * Callback invoked when a `confirmation_response` command is read.37 */38export type ConfirmationHandler = (requestId: string, allowed: boolean) => void;39 40/**41 * Callback type for submitting a query from remote input.42 * Returns true if the submit was accepted, false if rejected (TUI busy).43 */44export type SubmitFn = (45  query: string,46) => Promise<boolean | void> | boolean | void;47 48/**49 * Watches a JSONL file for remote input commands and calls the registered50 * submit function when new commands arrive.51 *52 * The watcher queues commands and retries when the TUI is busy (responding).53 * Call `notifyIdle()` when the TUI transitions to idle state to trigger54 * processing of queued commands.55 */56export class RemoteInputWatcher {57  private submitFn: SubmitFn | null = null;58  private confirmationHandler: ConfirmationHandler | null = null;59  private queue: Array<Extract<RemoteInputCommand, { type: 'submit' }>> = [];60  private processing = false;61  private active = true;62  private bytesRead = 0;63  private consumedPrefixHash: string | null = null;64  private reading = false;65  private filePath: string;66  private retryTimer: ReturnType<typeof setTimeout> | null = null;67  private readonly pollIntervalMs: number;68 69  constructor(filePath: string, options?: { pollIntervalMs?: number }) {70    this.filePath = filePath;71    this.pollIntervalMs = options?.pollIntervalMs ?? 500;72    this.startWatching();73  }74 75  /**76   * Register the TUI's submit function. Called from AppContainer77   * once useGeminiStream's submitQuery is available.78   */79  setSubmitFn(fn: SubmitFn): void {80    this.submitFn = fn;81    this.processQueue();82  }83 84  /**85   * Register the handler invoked when a `confirmation_response` command is86   * read from the input file. Used to bridge external approvals back into87   * the tool's `onConfirm` callback.88   */89  setConfirmationHandler(fn: ConfirmationHandler): void {90    this.confirmationHandler = fn;91  }92 93  /**94   * Notify the watcher that the TUI has become idle.95   * Call this when streamingState transitions to Idle — it triggers96   * processing of any queued commands that were deferred due to TUI busy.97   */98  notifyIdle(): void {99    if (this.queue.length > 0 && !this.processing) {100      this.processQueue();101    }102  }103 104  private startWatching(): void {105    try {106      const stat = statSync(this.filePath);107      this.bytesRead = stat.size;108      this.consumedPrefixHash = this.hashFilePrefix(this.bytesRead);109    } catch {110      this.bytesRead = 0;111      this.consumedPrefixHash = null;112    }113 114    watchFile(this.filePath, { interval: this.pollIntervalMs }, () => {115      if (!this.active) return;116      this.readNewLines();117    });118 119    debugLogger.debug(`RemoteInput: watching ${this.filePath}`);120  }121 122  /**123   * Manually trigger a check for new input. Returns a promise that resolves124   * once any new lines have been read and processed. In production the125   * `watchFile` poll calls this automatically; tests can call it directly126   * to avoid depending on filesystem-polling timing.127   */128  checkForNewInput(): Promise<void> {129    return this.readNewLines();130  }131 132  private readNewLines(): Promise<void> {133    if (!this.active || this.reading) return Promise.resolve();134 135    let currentSize: number;136    try {137      const stat = statSync(this.filePath);138      currentSize = stat.size;139    } catch {140      return Promise.resolve();141    }142 143    // Size alone misses truncate+rewrite that lands at the same or a larger144    // size. Append-only writes preserve the consumed prefix hash; rewrites do not.145    if (currentSize < this.bytesRead) {146      debugLogger.debug(147        'RemoteInput: input file shrank, resetting read offset',148      );149      this.bytesRead = 0;150      this.consumedPrefixHash = null;151    } else if (this.hasConsumedPrefixChanged()) {152      debugLogger.debug(153        'RemoteInput: input file prefix changed, resetting read offset',154      );155      this.bytesRead = 0;156      this.consumedPrefixHash = null;157    }158 159    if (currentSize <= this.bytesRead) return Promise.resolve();160 161    const consumeUntil = this.findLastCompleteRecordEnd(162      this.bytesRead,163      currentSize,164    );165    if (consumeUntil === null) {166      return Promise.resolve();167    }168 169    const nextConsumedPrefixHash = this.hashFilePrefix(consumeUntil);170    this.reading = true;171    const stream = createReadStream(this.filePath, {172      start: this.bytesRead,173      end: consumeUntil - 1,174      encoding: 'utf-8',175    });176    const rl = createInterface({ input: stream, crlfDelay: Infinity });177 178    rl.on('line', (line) => {179      const trimmed = line.trim();180      if (!trimmed) return;181      try {182        const cmd = JSON.parse(trimmed);183        // confirmation_response is dispatched immediately rather than queued:184        // a pending tool call is blocking and the response must reach185        // onConfirm without waiting for any earlier `submit` to finish.186        if (187          cmd &&188          cmd.type === 'confirmation_response' &&189          typeof cmd.request_id === 'string' &&190          typeof cmd.allowed === 'boolean'191        ) {192          debugLogger.debug(193            `RemoteInput: confirmation_response for ${cmd.request_id} (allowed=${cmd.allowed})`,194          );195          this.confirmationHandler?.(cmd.request_id, cmd.allowed);196        } else if (197          cmd &&198          cmd.type === 'submit' &&199          typeof cmd.text === 'string'200        ) {201          debugLogger.debug(202            `RemoteInput: queued command: ${cmd.text.slice(0, 50)}...`,203          );204          this.queue.push(205            cmd as Extract<RemoteInputCommand, { type: 'submit' }>,206          );207        } else {208          debugLogger.warn(209            `RemoteInput: unknown command type: ${String(cmd?.type)}`,210          );211        }212      } catch (_err) {213        debugLogger.warn(`RemoteInput: failed to parse line: ${trimmed}`);214      }215    });216 217    return new Promise<void>((resolve) => {218      rl.on('close', () => {219        this.bytesRead = consumeUntil;220        if (nextConsumedPrefixHash !== null) {221          this.consumedPrefixHash = nextConsumedPrefixHash;222        }223        this.reading = false;224        this.processQueue();225        resolve();226      });227    });228  }229 230  private findLastCompleteRecordEnd(start: number, end: number): number | null {231    if (end <= start) return null;232 233    let fd: number | null = null;234    try {235      fd = openSync(this.filePath, 'r');236      const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, end - start));237      let remaining = end - start;238      let position = start;239      let lastLineBreak = -1;240 241      while (remaining > 0) {242        const bytesToRead = Math.min(buffer.length, remaining);243        const bytesRead = readSync(fd, buffer, 0, bytesToRead, position);244        if (bytesRead <= 0) break;245        for (let i = 0; i < bytesRead; i++) {246          if (buffer[i] === 0x0a) {247            lastLineBreak = position + i;248          }249        }250        remaining -= bytesRead;251        position += bytesRead;252      }253 254      return lastLineBreak >= start ? lastLineBreak + 1 : null;255    } catch (err) {256      debugLogger.warn('RemoteInput: failed to scan complete records:', err);257      return null;258    } finally {259      if (fd !== null) {260        closeSync(fd);261      }262    }263  }264 265  private hasConsumedPrefixChanged(): boolean {266    if (this.bytesRead === 0) {267      return false;268    }269    if (this.consumedPrefixHash === null) {270      debugLogger.warn(271        'RemoteInput: missing consumed prefix hash, resetting read offset',272      );273      return true;274    }275 276    const currentHash = this.hashFilePrefix(this.bytesRead);277    if (currentHash === null) {278      debugLogger.warn(279        'RemoteInput: failed to hash consumed prefix, resetting read offset',280      );281      return true;282    }283    return currentHash !== this.consumedPrefixHash;284  }285 286  private hashFilePrefix(size: number): string | null {287    if (size <= 0) return null;288 289    let fd: number | null = null;290    try {291      fd = openSync(this.filePath, 'r');292      const hash = createHash('sha256');293      const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, size));294      let remaining = size;295      let position = 0;296 297      while (remaining > 0) {298        const bytesToRead = Math.min(buffer.length, remaining);299        const bytesRead = readSync(fd, buffer, 0, bytesToRead, position);300        if (bytesRead <= 0) return null;301        hash.update(buffer.subarray(0, bytesRead));302        remaining -= bytesRead;303        position += bytesRead;304      }305 306      return hash.digest('base64');307    } catch (err) {308      debugLogger.warn('RemoteInput: failed to hash file prefix:', err);309      return null;310    } finally {311      if (fd !== null) {312        closeSync(fd);313      }314    }315  }316 317  private async processQueue(): Promise<void> {318    if (this.processing || !this.submitFn || this.queue.length === 0) return;319 320    this.processing = true;321    if (this.retryTimer) {322      clearTimeout(this.retryTimer);323      this.retryTimer = null;324    }325 326    try {327      while (this.queue.length > 0 && this.active) {328        if (!this.submitFn) break;329        const cmd = this.queue[0]!; // peek, don't shift yet330        debugLogger.debug(331          `RemoteInput: submitting: ${cmd.text.slice(0, 50)}...`,332        );333        try {334          const result = await this.submitFn(cmd.text);335          // If submitFn returns false explicitly, the TUI rejected it (busy)336          if (result === false) {337            debugLogger.debug('RemoteInput: TUI busy, will retry on idle');338            this.scheduleRetry();339            break;340          }341          // Success — remove from queue342          this.queue.shift();343        } catch (err) {344          debugLogger.error('RemoteInput: submit failed:', err);345          this.queue.shift(); // remove failed command to avoid infinite retry346        }347        // Small delay between commands to let the TUI process348        if (this.queue.length > 0) {349          await new Promise((r) => setTimeout(r, 500));350        }351      }352    } finally {353      this.processing = false;354    }355  }356 357  private scheduleRetry(): void {358    if (this.retryTimer) return;359    // Retry after 2s if notifyIdle hasn't been called360    this.retryTimer = setTimeout(() => {361      this.retryTimer = null;362      if (this.queue.length > 0 && !this.processing) {363        this.processQueue();364      }365    }, 2000);366  }367 368  shutdown(): void {369    this.active = false;370    unwatchFile(this.filePath);371    if (this.retryTimer) clearTimeout(this.retryTimer);372    this.queue.length = 0;373    debugLogger.debug('RemoteInput: shut down');374  }375}376 
basant307/AI_Governance_Project · CoolFace