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.
- package/index.js +172 -8
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
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
|
-
|
|
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
|
|