gitdone-agent 0.7.0 → 0.7.3

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.
Files changed (2) hide show
  1. package/index.js +172 -8
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -27,7 +27,7 @@ import { randomUUID } from 'node:crypto'
27
27
  // Reported to the server on every sync so the web UI can flag outdated agents.
28
28
  // Keep in lockstep with packages/agent/package.json "version" AND
29
29
  // src/lib/agentVersion.ts LATEST_AGENT_VERSION.
30
- const AGENT_VERSION = '0.7.0'
30
+ const AGENT_VERSION = '0.7.1'
31
31
 
32
32
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
33
33
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -253,6 +253,99 @@ function stopRunningAgents() {
253
253
  } catch { /* best-effort — at worst the old agent lingers until next login */ }
254
254
  }
255
255
 
256
+ // ─── Watchdog (gd-431) ────────────────────────────────────────────────────────
257
+ // Third, OUTER layer of crash safety. Layers 1-2 (survive() in-process, the
258
+ // run-agent.cmd supervisor loop) still leave two ways for a machine to stay
259
+ // offline: the supervisor cmd.exe itself dying (nothing restarts IT), and the
260
+ // agent HANGING without exiting (process alive → supervisor waits forever).
261
+ // So: the agent touches a heartbeat file every 30s, and a Windows scheduled
262
+ // task runs watchdog.ps1 every 5 minutes (hidden via watchdog.vbs). A stale
263
+ // heartbeat (>10 min) means dead OR hung → the watchdog kills any leftover
264
+ // agent/supervisor processes and relaunches run-agent.cmd. The relaunch goes
265
+ // through WMI Win32_Process.Create so the agent does NOT live inside the task's
266
+ // job object — otherwise Task Scheduler would consider the task "still running"
267
+ // forever and never fire it again (and stopping the task would kill the agent).
268
+ //
269
+ // Broken-session guard (gd-405): if a restart attempt does NOT revive the
270
+ // heartbeat (0xc0000142 storms after a hardware crash — every start pops a
271
+ // blocking error dialog), the watchdog backs off to one attempt per 30 min via
272
+ // a stamp file instead of retrying every 5 — and a fresh heartbeat clears it.
273
+ const WATCHDOG_TASK = 'GitdoneAgentWatchdog'
274
+ const WATCHDOG_PS1 = join(AGENT_DIR, 'watchdog.ps1')
275
+ const WATCHDOG_VBS = join(AGENT_DIR, 'watchdog.vbs')
276
+ const HEARTBEAT_PATH = join(AGENT_DIR, 'heartbeat.txt')
277
+
278
+ function startHeartbeat() {
279
+ const beat = () => {
280
+ try { ensureAgentDir(); writeFileSync(HEARTBEAT_PATH, new Date().toISOString()) } catch { /* best-effort */ }
281
+ }
282
+ beat()
283
+ setInterval(beat, 30_000)
284
+ }
285
+
286
+ // Idempotent: (re)writes the watchdog scripts and (re)creates the scheduled
287
+ // task. Called on EVERY agent start — not just --install — so an in-place
288
+ // update (gd-420) gains the watchdog too, and a deleted task self-heals.
289
+ // The ps1 stays ASCII-only: PowerShell 5.1 misreads UTF-8-without-BOM files.
290
+ function ensureWatchdog() {
291
+ if (process.platform !== 'win32') return
292
+ try {
293
+ ensureAgentDir()
294
+ const runCmd = join(AGENT_DIR, 'run-agent.cmd')
295
+ const ps1 = [
296
+ "$ErrorActionPreference = 'SilentlyContinue'",
297
+ `$dir = '${AGENT_DIR.replace(/'/g, "''")}'`,
298
+ "$hb = Join-Path $dir 'heartbeat.txt'",
299
+ "$stamp = Join-Path $dir 'watchdog-restart.stamp'",
300
+ "$wlog = Join-Path $dir 'watchdog.log'",
301
+ "$runCmd = Join-Path $dir 'run-agent.cmd'",
302
+ 'function WLog([string]$m) { Add-Content -Path $wlog -Value ("[{0}] {1}" -f (Get-Date -Format \'yyyy-MM-dd HH:mm:ss\'), $m) }',
303
+ 'if ((Test-Path $wlog) -and ((Get-Item $wlog).Length -gt 1048576)) { Move-Item -Path $wlog -Destination ($wlog + \'.old\') -Force }',
304
+ '# Fresh heartbeat (<10 min) = agent alive and ticking - nothing to do.',
305
+ 'if ((Test-Path $hb) -and (((Get-Date) - (Get-Item $hb).LastWriteTime).TotalSeconds -lt 600)) {',
306
+ ' if (Test-Path $stamp) { Remove-Item -Path $stamp -Force }',
307
+ ' exit 0',
308
+ '}',
309
+ '# Last restart attempt did not revive the heartbeat - broken login session',
310
+ '# (e.g. 0xc0000142 after a hardware crash). Back off to one try per 30 min',
311
+ '# so retries cannot turn into a blocking-dialog storm (gd-405).',
312
+ 'if ((Test-Path $stamp) -and (((Get-Date) - (Get-Item $stamp).LastWriteTime).TotalMinutes -lt 30)) { exit 0 }',
313
+ 'New-Item -ItemType File -Path $stamp -Force | Out-Null',
314
+ "if (-not (Test-Path $runCmd)) { WLog 'run-agent.cmd missing - cannot restart; run gitdone-agent --install'; exit 0 }",
315
+ "WLog 'heartbeat stale - killing leftover agent processes and restarting'",
316
+ '# Same sweep as stopRunningAgents(): supervisors first, then agents, so a',
317
+ '# surviving supervisor cannot respawn a second agent mid-cleanup.',
318
+ 'Get-CimInstance Win32_Process -Filter "Name=\'cmd.exe\'" | Where-Object { $_.CommandLine -like \'*run-agent.cmd*\' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }',
319
+ 'Get-CimInstance Win32_Process -Filter "Name=\'node.exe\'" | Where-Object { $_.CommandLine -like \'*gitdone-agent*\' -or $_.CommandLine -like \'*agent.mjs*\' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }',
320
+ 'Start-Sleep -Seconds 2',
321
+ '# WMI Create = the new process tree lives OUTSIDE the scheduled task job.',
322
+ '# (wmiclass accelerator, not Invoke-CimMethod - CIM cannot marshal the',
323
+ '# embedded Win32_ProcessStartup instance and dies with "Type mismatch".)',
324
+ "$sup = ([wmiclass]'Win32_ProcessStartup').CreateInstance()",
325
+ '$sup.ShowWindow = 0',
326
+ "$r = ([wmiclass]'Win32_Process').Create(('\"' + $env:ComSpec + '\" /c \"\"' + $runCmd + '\"\"'), $null, $sup)",
327
+ 'WLog ("restart issued (ReturnValue={0}, Pid={1})" -f $r.ReturnValue, $r.ProcessId)',
328
+ ].join('\r\n')
329
+ writeFileSync(WATCHDOG_PS1, ps1, 'utf8')
330
+
331
+ // VBS wrapper so the 5-minute task never flashes a window.
332
+ const vbs = [
333
+ 'Set WshShell = CreateObject("WScript.Shell")',
334
+ `WshShell.Run "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ""${WATCHDOG_PS1}""", 0, True`,
335
+ ].join('\r\n')
336
+ writeFileSync(WATCHDOG_VBS, vbs, 'utf8')
337
+
338
+ execFileSync('schtasks', [
339
+ '/Create', '/F', '/SC', 'MINUTE', '/MO', '5',
340
+ '/TN', WATCHDOG_TASK,
341
+ '/TR', `wscript.exe "${WATCHDOG_VBS}"`,
342
+ ], { stdio: 'ignore', timeout: 30_000 })
343
+ log(`✓ watchdog: планирана задача „${WATCHDOG_TASK}" проверява агента на всеки 5 мин`)
344
+ } catch (err) {
345
+ log(`✗ watchdog setup failed: ${err?.message || err}`)
346
+ }
347
+ }
348
+
256
349
  function installStartup() {
257
350
  ensureAgentDir()
258
351
  const nodePath = getNodePath()
@@ -317,6 +410,9 @@ function installStartup() {
317
410
  ].join('\r\n')
318
411
 
319
412
  writeFileSync(getVbsPath(), vbs, 'utf8')
413
+
414
+ // Outer safety layer: the 5-minute watchdog task (gd-431).
415
+ ensureWatchdog()
320
416
  }
321
417
 
322
418
  function uninstallStartup() {
@@ -329,6 +425,16 @@ function uninstallStartup() {
329
425
  }
330
426
  const cmdPath = join(AGENT_DIR, 'run-agent.cmd')
331
427
  if (existsSync(cmdPath)) unlinkSync(cmdPath)
428
+ // Drop the watchdog task too — otherwise it resurrects the agent in ≤5 min.
429
+ if (process.platform === 'win32') {
430
+ try {
431
+ execFileSync('schtasks', ['/Delete', '/F', '/TN', WATCHDOG_TASK], { stdio: 'ignore', timeout: 30_000 })
432
+ console.log('✓ Премахнат watchdog (планирана задача)')
433
+ } catch { /* not installed */ }
434
+ }
435
+ for (const f of [WATCHDOG_PS1, WATCHDOG_VBS, HEARTBEAT_PATH]) {
436
+ if (existsSync(f)) unlinkSync(f)
437
+ }
332
438
  // Also stop the live supervisor + agent — otherwise they keep running (and
333
439
  // the supervisor keeps resurrecting the agent) until the next reboot.
334
440
  stopRunningAgents()
@@ -344,6 +450,14 @@ function runDoctor() {
344
450
  console.log(` stable agent : ${STABLE_AGENT} ${existsSync(STABLE_AGENT) ? '(ok)' : '(not installed)'}`)
345
451
  console.log(` autostart vbs : ${getVbsPath()} ${existsSync(getVbsPath()) ? '(ok)' : '(not installed)'}`)
346
452
  console.log(` supervisor : ${join(AGENT_DIR, 'run-agent.cmd')} ${existsSync(join(AGENT_DIR, 'run-agent.cmd')) ? '(ok)' : '(not installed — пусни --install за авторестарт при crash)'}`)
453
+ if (process.platform === 'win32') {
454
+ let wd = '(not installed — пусни --install или рестартирай агента)'
455
+ try { execFileSync('schtasks', ['/Query', '/TN', WATCHDOG_TASK], { stdio: 'ignore', timeout: 30_000 }); wd = '(ok — на всеки 5 мин)' } catch { /* missing */ }
456
+ console.log(` watchdog : ${WATCHDOG_TASK} ${wd}`)
457
+ }
458
+ let hb = '(none yet)'
459
+ try { hb = `(последен: преди ${Math.round((Date.now() - statSync(HEARTBEAT_PATH).mtimeMs) / 1000)}s)` } catch { /* no beat yet */ }
460
+ console.log(` heartbeat : ${HEARTBEAT_PATH} ${hb}`)
347
461
  console.log(` config : ${CONFIG_PATH} ${existsSync(CONFIG_PATH) ? '(ok)' : '(MISSING)'}`)
348
462
  console.log(` log file : ${LOG_PATH} ${existsSync(LOG_PATH) ? '(ok)' : '(none yet)'}`)
349
463
  console.log(` crash log : ${join(AGENT_DIR, 'agent-crash.log')} ${existsSync(join(AGENT_DIR, 'agent-crash.log')) ? '(има записи — виж го при проблеми)' : '(празен — няма крашове)'}`)
@@ -447,6 +561,28 @@ function gitTry(cmd, cwd) {
447
561
  }
448
562
  }
449
563
 
564
+ // Like gitTry(), but hands git an ARGV array instead of a shell command string,
565
+ // so git.exe runs directly with no shell in between. A shell string goes through
566
+ // cmd.exe on Windows, which (a) mangles Cyrillic and (b) treats &, |, %, ^, <, >
567
+ // as operators — so a Bulgarian commit message, or a path containing such a
568
+ // character, silently corrupted the command and the commit failed. The old
569
+ // commit path swallowed that via git() + `|| 'committed'` and reported a fake
570
+ // success while nothing was committed (gd-445). gitDiffUntracked() already uses
571
+ // execFileSync for the same reason. Local calls get 2 min; pass net for 5.
572
+ function gitArgs(args, cwd, { net = false } = {}) {
573
+ try {
574
+ const out = execFileSync('git', args, {
575
+ cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
576
+ maxBuffer: GIT_MAX_BUFFER, timeout: net ? GIT_NET_TIMEOUT_MS : GIT_TIMEOUT_MS, env: GIT_ENV,
577
+ }).trim()
578
+ return { ok: true, out }
579
+ } catch (err) {
580
+ const timedOut = err.signal === 'SIGTERM' && err.code == null
581
+ const out = (err.stderr || err.stdout || (timedOut ? `git не отговори и беше прекратен` : err.message) || '').toString().trim()
582
+ return { ok: false, out }
583
+ }
584
+ }
585
+
450
586
  // Never let a token reach the log file or the server's command-result.
451
587
  function redact(text, token) {
452
588
  if (!text || !token) return text
@@ -648,7 +784,18 @@ function getSnapshot(repoPath) {
648
784
  // -uall lists every untracked file individually instead of collapsing a new
649
785
  // directory into a single `dir/` entry — so the change list matches what
650
786
  // GitHub Desktop shows, file-for-file (gd-369).
651
- const statusLines = git('git status --porcelain -uall', repoPath).split('\n').filter(Boolean)
787
+ // Read the status RAW (not via git(), which .trim()s the whole output). The
788
+ // porcelain format is fixed-width — 2 status columns, a space, then the path —
789
+ // and the first line of a worktree-only change starts with a space (e.g.
790
+ // " M .env.example"). git().trim() ate that leading space on the FIRST line
791
+ // only, shifting its columns so the path lost its first character (".env.example"
792
+ // → "env.example"); the mangled path then failed `git add` (or, before gd-445,
793
+ // failed silently). Strip only a trailing \r per line, never leading spaces (gd-446).
794
+ const statusLines = gitRaw('git status --porcelain -uall', repoPath)
795
+ .toString('utf8')
796
+ .split('\n')
797
+ .map((l) => l.replace(/\r$/, ''))
798
+ .filter(Boolean)
652
799
  const modified = []
653
800
  const staged = []
654
801
  const statuses = {}
@@ -1586,20 +1733,32 @@ async function executeCommand(cfg, cmd, repoPath) {
1586
1733
  const auth = cfg.auth?.[repoPath] ?? null
1587
1734
  try {
1588
1735
  if (cmd.type === 'commit') {
1589
- const msg = (cmd.payload?.message ?? 'commit').replace(/"/g, '\\"')
1736
+ // Everything here goes through gitArgs (argv, no shell): the message and the
1737
+ // paths are user/Cyrillic data that cmd.exe would corrupt, which used to make
1738
+ // the commit fail silently while the console showed a fake ✓ (gd-445).
1739
+ const msg = (cmd.payload?.message ?? '').trim() || 'commit'
1590
1740
  // When the UI sends a list of selected files, stage ONLY those so the
1591
1741
  // resulting commit (and the push that follows) contains just the checked
1592
1742
  // files. No list → stage everything (old behaviour / older UIs).
1593
1743
  const files = Array.isArray(cmd.payload?.files)
1594
1744
  ? cmd.payload.files.filter((f) => typeof f === 'string' && f.trim())
1595
1745
  : []
1596
- if (files.length) {
1597
- const quoted = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(' ')
1598
- git(`git add -- ${quoted}`, repoPath)
1746
+ const add = files.length
1747
+ ? gitArgs(['add', '--', ...files], repoPath)
1748
+ : gitArgs(['add', '-A'], repoPath)
1749
+ if (!add.ok) throw new Error(`git add се провали: ${add.out}`)
1750
+ const commit = gitArgs(['commit', '-m', msg], repoPath)
1751
+ if (commit.ok) {
1752
+ result = commit.out || 'committed'
1753
+ } else if (/nothing to commit|no changes added|working tree clean/i.test(commit.out)) {
1754
+ // Benign: the checked files carried no staged change (already committed or
1755
+ // whitespace-only). Say so plainly instead of faking success.
1756
+ result = 'няма промени за commit'
1599
1757
  } else {
1600
- git('git add -A', repoPath)
1758
+ // Real failure (hook, identity, lock, corrupt path…) — surface it so the
1759
+ // console shows the reason instead of the old silent fake ✓.
1760
+ throw new Error(commit.out || 'git commit се провали')
1601
1761
  }
1602
- result = git(`git commit -m "${msg}"`, repoPath) || 'committed'
1603
1762
  } else if (cmd.type === 'push' || cmd.type === 'pull') {
1604
1763
  result = pushOrPull(cmd.type, repoPath, auth)
1605
1764
  } else if (cmd.type === 'discard') {
@@ -1809,6 +1968,11 @@ async function streamCommands(cfg) {
1809
1968
 
1810
1969
  async function runLoop(cfg) {
1811
1970
  ensureSingleInstance()
1971
+ // Watchdog food: a heartbeat file every 30s. Stale >10 min (dead OR hung
1972
+ // event loop) → the scheduled task restarts us (gd-431). Registered on every
1973
+ // start so in-place-updated agents get the watchdog without a re-install.
1974
+ startHeartbeat()
1975
+ ensureWatchdog()
1812
1976
  log(`gitdone-agent started — machine: ${cfg.hostname}, server: ${cfg.url}, interval: ${cfg.interval}s`)
1813
1977
  log(` roots: ${cfg.roots.join(', ') || '(none — add one with --root)'}`)
1814
1978
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.7.0",
3
+ "version": "0.7.3",
4
4
  "description": "Local git agent for gitdone — watches a local repo and sends snapshots to gitdone.eu",
5
5
  "type": "module",
6
6
  "bin": {