gitdone-agent 0.7.6 → 0.7.7

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 +101 -9
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -13,6 +13,7 @@ import { execSync, execFileSync, spawn } from 'node:child_process'
13
13
  import {
14
14
  existsSync, writeFileSync, readFileSync, unlinkSync, renameSync,
15
15
  mkdirSync, copyFileSync, appendFileSync, readdirSync, rmSync, statSync,
16
+ openSync, writeSync, fsyncSync, closeSync,
16
17
  } from 'node:fs'
17
18
  import { resolve, join } from 'node:path'
18
19
  import { homedir, hostname, tmpdir } from 'node:os'
@@ -27,10 +28,12 @@ import { randomUUID, createHash } from 'node:crypto'
27
28
  // Reported to the server on every sync so the web UI can flag outdated agents.
28
29
  // Keep in lockstep with packages/agent/package.json "version" AND
29
30
  // src/lib/agentVersion.ts LATEST_AGENT_VERSION.
30
- const AGENT_VERSION = '0.7.6'
31
+ const AGENT_VERSION = '0.7.7'
31
32
 
32
33
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
33
34
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
35
+ // Last-known-good copy of config.json, refreshed once per successful start.
36
+ const CONFIG_BAK_PATH = CONFIG_PATH + '.bak'
34
37
  const STABLE_AGENT = join(AGENT_DIR, 'agent.mjs')
35
38
  const LOG_PATH = join(AGENT_DIR, 'agent.log')
36
39
 
@@ -80,20 +83,98 @@ function survive(kind, err) {
80
83
  process.on('uncaughtException', (err) => survive('uncaught exception', err))
81
84
  process.on('unhandledRejection', (err) => survive('unhandled rejection', err))
82
85
 
83
- function readConfig() {
84
- try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) } catch { return null }
86
+ function parseConfigFile(path) {
87
+ try {
88
+ // Strip a UTF-8 BOM: we never write one, but a config repaired by hand in an
89
+ // editor that adds one would otherwise be unparsable — and "I fixed the file
90
+ // and it still says no config" is the worst possible dead end (gd-478).
91
+ const cfg = JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''))
92
+ return cfg && typeof cfg === 'object' && cfg.key ? cfg : null
93
+ } catch { return null }
94
+ }
95
+
96
+ // Rebuild the bare minimum from ai-mcp-config.json — last resort when both
97
+ // config.json and its backup are gone. It is written from cfg.key/cfg.machineId
98
+ // on every AI session, so it carries the two values that cannot be regenerated
99
+ // locally (a new machineId would show up as a SECOND machine on the server, and
100
+ // there is no way to invent the API key). Roots are lost, but an agent that is
101
+ // online with no roots can be fixed from the web UI; an offline one cannot.
102
+ function recoverConfigFromAiMcp() {
103
+ try {
104
+ const mcp = JSON.parse(readFileSync(join(AGENT_DIR, 'ai-mcp-config.json'), 'utf8'))
105
+ const server = mcp?.mcpServers?.gitdone
106
+ const key = server?.headers?.Authorization?.replace(/^Bearer\s+/, '')
107
+ const machineId = server?.headers?.['X-Gitdone-Machine-Id']
108
+ if (!key || !machineId || !server.url) return null
109
+ return {
110
+ key,
111
+ url: server.url.replace(/\/api\/mcp\/?$/, ''),
112
+ interval: 30,
113
+ machineId,
114
+ hostname: hostname(),
115
+ roots: [],
116
+ }
117
+ } catch { return null }
85
118
  }
86
119
 
120
+ // True when the running config had to be rebuilt — surfaced by --doctor.
121
+ let configWasRecovered = null
122
+
123
+ // An unclean shutdown (power loss, BSOD) can leave config.json committed by the
124
+ // filesystem as a NUL-filled file of the right length: intact on disk, garbage
125
+ // to JSON.parse. Before gd-478 that bricked the agent for good — every autostart
126
+ // exited with "no config found" and the supervisor just respawned it every 10s,
127
+ // so the machine stayed offline until somebody hand-edited the file. A user
128
+ // without filesystem access had no way out at all. So never trust the primary
129
+ // copy blindly: fall back to the last-known-good backup, then to the AI MCP
130
+ // config, and put whichever survived back in place.
131
+ function readConfig() {
132
+ const primary = parseConfigFile(CONFIG_PATH)
133
+ if (primary) return primary
134
+ // Nothing to recover from on a fresh machine (install path) — stay quiet.
135
+ if (!existsSync(CONFIG_PATH) && !existsSync(CONFIG_BAK_PATH)) return null
136
+
137
+ for (const [source, cfg] of [
138
+ ['config.json.bak', parseConfigFile(CONFIG_BAK_PATH)],
139
+ ['ai-mcp-config.json', recoverConfigFromAiMcp()],
140
+ ]) {
141
+ if (!cfg) continue
142
+ configWasRecovered = source
143
+ log(`✗ config.json е повреден (най-вероятно от неочаквано изключване) — възстановен от ${source}`)
144
+ try { writeConfig(cfg) } catch (err) { log(`✗ записът на възстановения config не успя: ${err.message}`) }
145
+ return cfg
146
+ }
147
+ log('✗ config.json е повреден и няма от какво да се възстанови — пусни gitdone-agent --key=... --root=... --install')
148
+ return null
149
+ }
150
+
151
+ // Atomic AND durable: write to a temp file, force it to the platter, and only
152
+ // then rename over the real one. The fsync is the part that matters — without
153
+ // it the rename can be committed while the data is still in the page cache, and
154
+ // a power cut in that window is exactly what produced the NUL-filled config in
155
+ // gd-478. Losing the rename is harmless (the old config survives); losing the
156
+ // data is not.
87
157
  function writeConfig(cfg) {
88
158
  ensureAgentDir()
89
- // Write-then-rename so a crash / reboot mid-write can never leave a truncated
90
- // config.json — a corrupt config makes every subsequent autostart exit
91
- // immediately, which reads as "агентът умря и рестартът не помага" (gd-403).
92
159
  const tmp = CONFIG_PATH + '.tmp'
93
- writeFileSync(tmp, JSON.stringify(cfg, null, 2), 'utf8')
160
+ const fd = openSync(tmp, 'w')
161
+ try {
162
+ writeSync(fd, JSON.stringify(cfg, null, 2), null, 'utf8')
163
+ fsyncSync(fd)
164
+ } finally {
165
+ closeSync(fd)
166
+ }
94
167
  renameSync(tmp, CONFIG_PATH)
95
168
  }
96
169
 
170
+ // Refresh the last-known-good copy. Called once per successful start, so the
171
+ // backup is always a config that actually booted the agent — never a half-baked
172
+ // one. Best-effort: a failed backup must not stop the agent from running.
173
+ function backupConfig(cfg) {
174
+ try { writeFileSync(CONFIG_BAK_PATH, JSON.stringify(cfg, null, 2), 'utf8') }
175
+ catch (err) { log(`✗ backup на config.json не успя: ${err.message}`) }
176
+ }
177
+
97
178
  // ─── Arg parsing ─────────────────────────────────────────────────────────────
98
179
 
99
180
  function parseArgs() {
@@ -458,7 +539,12 @@ function runDoctor() {
458
539
  let hb = '(none yet)'
459
540
  try { hb = `(последен: преди ${Math.round((Date.now() - statSync(HEARTBEAT_PATH).mtimeMs) / 1000)}s)` } catch { /* no beat yet */ }
460
541
  console.log(` heartbeat : ${HEARTBEAT_PATH} ${hb}`)
461
- console.log(` config : ${CONFIG_PATH} ${existsSync(CONFIG_PATH) ? '(ok)' : '(MISSING)'}`)
542
+ // cfg comes from readConfig() above, so a corrupt config.json has already been
543
+ // repaired by the time we print this — say so rather than a bare "(ok)".
544
+ const cfgState = configWasRecovered ? `(ВЪЗСТАНОВЕН от ${configWasRecovered} — беше повреден)`
545
+ : cfg ? '(ok)' : existsSync(CONFIG_PATH) ? '(ПОВРЕДЕН)' : '(MISSING)'
546
+ console.log(` config : ${CONFIG_PATH} ${cfgState}`)
547
+ console.log(` config backup : ${CONFIG_BAK_PATH} ${parseConfigFile(CONFIG_BAK_PATH) ? '(ok)' : '(няма — прави се при първия успешен старт)'}`)
462
548
  console.log(` log file : ${LOG_PATH} ${existsSync(LOG_PATH) ? '(ok)' : '(none yet)'}`)
463
549
  console.log(` crash log : ${join(AGENT_DIR, 'agent-crash.log')} ${existsSync(join(AGENT_DIR, 'agent-crash.log')) ? '(има записи — виж го при проблеми)' : '(празен — няма крашове)'}`)
464
550
  const claude = findClaude()
@@ -2094,6 +2180,9 @@ async function runLoop(cfg) {
2094
2180
  // start so in-place-updated agents get the watchdog without a re-install.
2095
2181
  startHeartbeat()
2096
2182
  ensureWatchdog()
2183
+ // This config just booted an agent, so it is known-good — snapshot it for
2184
+ // readConfig() to fall back on (gd-478).
2185
+ backupConfig(cfg)
2097
2186
  log(`gitdone-agent started — machine: ${cfg.hostname}, server: ${cfg.url}, interval: ${cfg.interval}s`)
2098
2187
  log(` roots: ${cfg.roots.join(', ') || '(none — add one with --root)'}`)
2099
2188
 
@@ -2169,7 +2258,10 @@ async function main() {
2169
2258
  // No args → running mode (this is how autostart launches us): read config.
2170
2259
  const cfg = readConfig()
2171
2260
  if (!cfg || !cfg.key) {
2172
- console.error(`Error: no config found at ${CONFIG_PATH}. Run with --key=... --root=... --install first.`)
2261
+ // Through agent.log, not just stderr the autostart runs hidden, so the
2262
+ // crash log is the only other place this would land and nobody reads it
2263
+ // until someone already suspects the agent (gd-478).
2264
+ log(`✗ няма използваем config в ${CONFIG_PATH} — пусни gitdone-agent --key=... --root=... --install`)
2173
2265
  process.exit(1)
2174
2266
  }
2175
2267
  await runLoop(cfg)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
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": {