File size: 3,418 Bytes
c0af099
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
/**
 * Shared file logger for agent-canvas dev scripts.
 *
 * Writes log output to a daily-rotating file under
 * <state-dir>/logs/agent-canvas.YYYY-MM-DD.log (7-day retention) alongside
 * the existing console output (which is unchanged).
 *
 * winston is loaded dynamically and treated as optional: when it isn't
 * resolvable — most importantly inside the packaged Electron desktop app,
 * whose `afterPack` hook strips `Resources/app/node_modules/` — `fileLog`
 * becomes a no-op and console logging continues to work unchanged. See
 * AGENTS.md "Electron desktop packaging" for the strip-hook details.
 */

import { mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import process from "node:process";

// Mirror the state-directory logic from dev-safe.mjs so log files live
// alongside all other agent-canvas runtime state (e.g. ~/.openhands/agent-canvas).
// The same env var (OH_CANVAS_SAFE_STATE_DIR) overrides both.
const stateDir =
  process.env.OH_CANVAS_SAFE_STATE_DIR ||
  join(homedir(), ".openhands", "agent-canvas");
const logDir = join(stateDir, "logs");

// Matches any ANSI CSI escape sequence (colors, cursor movement, etc.).
const ANSI_RE = /\x1b\[[0-9;]*m/g;

/**
 * Remove ANSI escape codes so log files contain clean plain text.
 * @param {string} str
 * @returns {string}
 */
export function stripAnsi(str) {
  return typeof str === "string" ? str.replace(ANSI_RE, "") : String(str);
}

/**
 * Attempt to construct a winston-backed file logger. Returns `null` if
 * winston isn't installed (packaged desktop app) or any setup step fails;
 * `fileLog` then degrades to a no-op.
 *
 * @returns {Promise<import("winston").Logger | null>}
 */
async function createFileLogger() {
  let winston;
  let DailyRotateFileMod;
  try {
    winston = await import("winston");
    DailyRotateFileMod = await import("winston-daily-rotate-file");
  } catch {
    return null;
  }

  try {
    mkdirSync(logDir, { recursive: true });

    const DailyRotateFile =
      DailyRotateFileMod.default ?? DailyRotateFileMod;
    const fileTransport = new DailyRotateFile({
      dirname: logDir,
      filename: "agent-canvas.%DATE%.log",
      datePattern: "YYYY-MM-DD",
      maxFiles: "7d",
      auditFile: join(logDir, ".log-audit.json"),
      createSymlink: false,
    });

    const logger = winston.createLogger({
      level: "debug",
      format: winston.format.combine(
        winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
        winston.format.printf(
          ({ timestamp, level, message }) =>
            `${timestamp} [${level.toUpperCase().padEnd(5)}] ${message}`,
        ),
      ),
      transports: [fileTransport],
    });

    // Swallow any transport-level errors (e.g. disk full) so a logging
    // failure never crashes the dev server.
    logger.on("error", () => {});
    fileTransport.on("error", () => {});
    return logger;
  } catch {
    return null;
  }
}

const fileLogger = await createFileLogger();

/**
 * Write a message to the rotating log file. No-ops when winston isn't
 * available (e.g. the packaged desktop app). ANSI escape codes are
 * stripped automatically; console output is unaffected.
 *
 * @param {'info' | 'warn' | 'error' | 'debug'} level
 * @param {string} message
 */
export function fileLog(level, message) {
  if (!fileLogger) return;
  fileLogger.log(level, stripAnsi(message));
}