gitdone-agent 0.5.4 → 0.6.0

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 +205 -10
  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.5.4'
30
+ const AGENT_VERSION = '0.6.0'
31
31
 
32
32
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
33
33
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -264,20 +264,71 @@ function authUrl(auth) {
264
264
  return `https://x-access-token:${auth.token}@github.com/${repo}.git`
265
265
  }
266
266
 
267
+ // A push rejected because the remote branch moved ahead of us (someone else
268
+ // pushed in the meantime). Git prints "non-fast-forward" / "fetch first" /
269
+ // "tip of your current branch is behind". We recover by pulling+rebasing.
270
+ const NON_FAST_FORWARD = /non-fast-forward|fetch first|tip of your current branch is behind|failed to push some refs/i
271
+
272
+ // Point the local remote-tracking ref (e.g. refs/remotes/origin/master) at the
273
+ // commit we just synced. We push/pull through the ad-hoc token URL, and — unlike
274
+ // `git push origin` — pushing/pulling by URL leaves refs/remotes/origin/* UNTOUCHED.
275
+ // getSnapshot() then measures "ahead"/"behind" against that stale tracking ref
276
+ // (git rev-list @{u}..HEAD), so already-pushed commits keep showing as pending
277
+ // forever: the ahead badge never clears, GitHub Desktop shows the same "N↑", and
278
+ // the user pushes again and again while local commits pile up (gd-273). Fast-
279
+ // forwarding the tracking ref to the true remote tip resets ahead/behind to reality.
280
+ // Best-effort: git() swallows failures so this never breaks the push/pull itself.
281
+ function updateTrackingRef(repoPath, branch, commitish) {
282
+ // Prefer the branch's configured upstream (usually origin/<branch>); fall back
283
+ // to origin/<branch> when no upstream is set.
284
+ const upstream = git('git rev-parse --abbrev-ref --symbolic-full-name @{u}', repoPath)
285
+ const ref = upstream ? `refs/remotes/${upstream}` : `refs/remotes/origin/${branch}`
286
+ git(`git update-ref "${ref}" ${commitish}`, repoPath)
287
+ }
288
+
267
289
  // Push/pull using the server-issued token when we have one; otherwise fall back
268
290
  // to the machine's own git credentials (old behaviour). Throws on failure.
269
291
  function pushOrPull(type, repoPath, auth) {
270
292
  const branch = git('git branch --show-current', repoPath) || 'HEAD'
271
- let cmd
272
- if (auth) {
273
- const url = authUrl(auth)
274
- cmd = type === 'push' ? `git push "${url}" HEAD:${branch}` : `git pull "${url}" ${branch}`
275
- } else {
276
- cmd = type === 'push' ? 'git push' : 'git pull'
293
+ const url = auth ? authUrl(auth) : null
294
+ const pushCmd = url ? `git push "${url}" HEAD:${branch}` : 'git push'
295
+
296
+ if (type === 'pull') {
297
+ const r = gitTry(url ? `git pull "${url}" ${branch}` : 'git pull', repoPath)
298
+ if (!r.ok) throw new Error(r.out || 'pull failed')
299
+ // Pulling by URL never advanced origin/<branch>; point it at the fetched tip
300
+ // (FETCH_HEAD) so a later push isn't seen as being "ahead" of a stale ref.
301
+ if (url) updateTrackingRef(repoPath, branch, 'FETCH_HEAD')
302
+ return r.out || 'pulled'
303
+ }
304
+
305
+ let r = gitTry(pushCmd, repoPath)
306
+ if (r.ok) {
307
+ // We just pushed HEAD to the remote branch, so the remote tip == HEAD. Sync
308
+ // the local tracking ref to clear the "ahead" count (gd-273).
309
+ if (url) updateTrackingRef(repoPath, branch, 'HEAD')
310
+ return r.out || 'pushed'
277
311
  }
278
- const r = gitTry(cmd, repoPath)
279
- if (!r.ok) throw new Error(r.out || `${type} failed`)
280
- return r.out || (type === 'push' ? 'pushed' : 'pulled')
312
+
313
+ // Push rejected because the remote is ahead (gd-272): commit succeeded but the
314
+ // push bounced with non-fast-forward, so the files looked "pushed" while the
315
+ // UI showed a scary git error. Pull with --rebase to replay our commits on top
316
+ // of the remote ones, then push again — what the user means by "push my files".
317
+ if (NON_FAST_FORWARD.test(r.out)) {
318
+ const pull = gitTry(url ? `git pull --rebase "${url}" ${branch}` : 'git pull --rebase', repoPath)
319
+ if (!pull.ok) {
320
+ // Rebase couldn't apply cleanly (conflicts) — abort so the repo isn't left
321
+ // mid-rebase, and surface an actionable message instead of guessing.
322
+ gitTry('git rebase --abort', repoPath)
323
+ throw new Error(`отдалеченият клон е напред и има конфликт при обединяване — дръпни (Sync) и слей ръчно, после пусни пак.\n${pull.out}`)
324
+ }
325
+ r = gitTry(pushCmd, repoPath)
326
+ if (!r.ok) throw new Error(r.out || 'push failed')
327
+ if (url) updateTrackingRef(repoPath, branch, 'HEAD')
328
+ return `дръпнах новите промени от сървъра и пушнах наново.\n${r.out}`.trim()
329
+ }
330
+
331
+ throw new Error(r.out || 'push failed')
281
332
  }
282
333
 
283
334
  const AUTH_FAIL = /authentication|authorization|403|401|denied|could not read Username|invalid username or password|terminal prompts disabled/i
@@ -761,6 +812,38 @@ function runAiChat(cfg, cmd, repoPath) {
761
812
  })
762
813
  }
763
814
 
815
+ // deploy — run the project's deploy script (scripts/deploy.sh) in the repo.
816
+ // Long-running (git pull + npm build + service restart), so we spawn it detached
817
+ // from the poll loop and report the result on exit. The agent is its OWN process
818
+ // (not part of any gitdone systemd service), so a `systemctl restart` inside the
819
+ // script doesn't kill the deploy mid-flight — which a server-side spawn would.
820
+ function runDeployCommand(cfg, cmd, repoPath) {
821
+ const script = 'scripts/deploy.sh'
822
+ if (!existsSync(join(repoPath, script))) {
823
+ reportCommandResult(cfg, cmd.id, 'error', `няма ${script} в repo-то — няма какво да деплойна`)
824
+ log(`✗ deploy @ ${repoPath}: липсва ${script}`)
825
+ return
826
+ }
827
+ log(`▸ deploy стартиран @ ${repoPath}`)
828
+ const child = spawn('bash', [script], { cwd: repoPath, stdio: ['ignore', 'pipe', 'pipe'] })
829
+ // Keep only the tail of the output — enough to explain a failure without
830
+ // shipping a whole build log back.
831
+ let tail = ''
832
+ const capture = (d) => { tail = (tail + d.toString()).slice(-2000) }
833
+ child.stdout.on('data', capture)
834
+ child.stderr.on('data', capture)
835
+ child.on('error', (err) => {
836
+ reportCommandResult(cfg, cmd.id, 'error', `процесна грешка: ${err.message}`)
837
+ log(`✗ deploy failed @ ${repoPath}: ${err.message}`)
838
+ })
839
+ child.on('close', (code) => {
840
+ const ok = code === 0
841
+ const detail = tail.trim() ? `\n${tail.trim()}` : ''
842
+ reportCommandResult(cfg, cmd.id, ok ? 'done' : 'error', `exit ${code}${detail}`.slice(0, 4000))
843
+ log(`${ok ? '✓' : '✗'} deploy @ ${repoPath} приключи (code ${code})`)
844
+ })
845
+ }
846
+
764
847
  async function executeCommand(cfg, cmd, repoPath) {
765
848
  // ai_run is long-running + streaming — launch it in the background and return
766
849
  // so the snapshot loop keeps going. It reports its own result on exit.
@@ -773,6 +856,13 @@ async function executeCommand(cfg, cmd, repoPath) {
773
856
  runAiChat(cfg, cmd, repoPath)
774
857
  return
775
858
  }
859
+ // deploy — run the repo's deploy script. Long-running (pull + build + service
860
+ // restart), so it's launched in the background and reports its own result on
861
+ // exit, same as ai_run.
862
+ if (cmd.type === 'deploy') {
863
+ runDeployCommand(cfg, cmd, repoPath)
864
+ return
865
+ }
776
866
 
777
867
  let result = 'ok'
778
868
  let status = 'done'
@@ -797,6 +887,14 @@ async function executeCommand(cfg, cmd, repoPath) {
797
887
  result = pushOrPull(cmd.type, repoPath, auth)
798
888
  } else if (cmd.type === 'discard') {
799
889
  result = discardFile(repoPath, cmd.payload?.file)
890
+ } else {
891
+ // Unknown command type — almost always a newer server feature (e.g. the
892
+ // gd-255 `deploy` command) reaching an agent too old to handle it. Do NOT
893
+ // silently mark it done: an older agent used to fall through here and
894
+ // report status=done/result=ok, so a queued deploy looked "completed" while
895
+ // nothing ran. Surface it as an error so the failure is visible and the
896
+ // user knows to update the agent.
897
+ throw new Error(`непозната команда „${cmd.type}" — обнови gitdone-agent (npm i -g gitdone-agent@latest)`)
800
898
  }
801
899
  log(`✓ ${cmd.type} @ ${repoPath}: ${redact(result, auth?.token)}`)
802
900
  } catch (err) {
@@ -850,6 +948,99 @@ async function pushSnapshot(cfg, repo) {
850
948
  return snapshot
851
949
  }
852
950
 
951
+ // ─── Low-latency command channel (gd-274) ───────────────────────────────────────
952
+ // The 30s snapshot poll is fine for reflecting git state, but makes user actions
953
+ // (Push/Pull/Commit/АИ) feel sluggish: a queued command waits up to a full tick
954
+ // before the agent even asks for it. So we ALSO hold a persistent SSE connection
955
+ // to the server and drain+run commands the instant it says "wake". The snapshot
956
+ // poll stays as the reliable fallback (and cron heartbeat) if the stream drops.
957
+
958
+ const GIT_STATE_CMDS = new Set(['commit', 'push', 'pull', 'discard'])
959
+
960
+ // Pull this machine's pending commands in one shot (no git/snapshot work server-
961
+ // side) and run them. Serialised via a tiny mutex so overlapping wakes don't
962
+ // double-drain; a wake arriving mid-drain sets a flag to run once more after.
963
+ let draining = false
964
+ let drainAgain = false
965
+ async function drainCommands(cfg) {
966
+ if (draining) { drainAgain = true; return }
967
+ draining = true
968
+ try {
969
+ do {
970
+ drainAgain = false
971
+ let data
972
+ try {
973
+ data = await api(cfg, '/api/v1/agent/commands', { machineId: cfg.machineId })
974
+ } catch (err) {
975
+ log(`✗ command drain failed: ${err.message}`)
976
+ return
977
+ }
978
+ const commands = data.commands ?? []
979
+ if (commands.length === 0) continue
980
+
981
+ // Cache any push tokens the server issued for these commands' repos.
982
+ if (data.githubAuth && typeof data.githubAuth === 'object') {
983
+ cfg.auth = cfg.auth ?? {}
984
+ for (const [path, a] of Object.entries(data.githubAuth)) {
985
+ if (a && a.token && a.repo) cfg.auth[path] = a
986
+ }
987
+ writeConfig(cfg)
988
+ }
989
+
990
+ // Run each command against its own repo path, and remember which repos had
991
+ // their git state changed so we can push a fresh snapshot right after —
992
+ // that's what makes the UI update instantly instead of on the next tick.
993
+ const touched = new Map()
994
+ for (const cmd of commands) {
995
+ const repoPath = cmd.path
996
+ if (!repoPath) { log(`✗ command ${cmd.id} has no path — skipped`); continue }
997
+ await executeCommand(cfg, cmd, repoPath)
998
+ if (GIT_STATE_CMDS.has(cmd.type)) touched.set(repoPath, cmd.repoName || repoPath)
999
+ }
1000
+ for (const [path, name] of touched) {
1001
+ try { await pushSnapshot(cfg, { path, name }) }
1002
+ catch (err) { log(`✗ post-command snapshot failed @ ${path}: ${err.message}`) }
1003
+ }
1004
+ } while (drainAgain)
1005
+ } finally {
1006
+ draining = false
1007
+ }
1008
+ }
1009
+
1010
+ // Hold a persistent SSE connection open and drain the moment the server pushes a
1011
+ // `wake`. Reconnects forever with a short backoff; the server also recycles the
1012
+ // connection every few minutes (each reconnect re-sends an initial wake, so
1013
+ // anything queued while we were away is picked up). Never throws.
1014
+ const STREAM_RECONNECT_MS = 3000
1015
+ async function streamCommands(cfg) {
1016
+ const url = `${cfg.url}/api/v1/agent/commands/stream?machineId=${encodeURIComponent(cfg.machineId)}`
1017
+ for (;;) {
1018
+ try {
1019
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${cfg.key}` } })
1020
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
1021
+ log('▶ command stream connected')
1022
+ const reader = res.body.getReader()
1023
+ const decoder = new TextDecoder()
1024
+ let buf = ''
1025
+ for (;;) {
1026
+ const { value, done } = await reader.read()
1027
+ if (done) break
1028
+ buf += decoder.decode(value, { stream: true })
1029
+ // SSE events are separated by a blank line; a `wake` means "drain now".
1030
+ let idx
1031
+ while ((idx = buf.indexOf('\n\n')) >= 0) {
1032
+ const frame = buf.slice(0, idx)
1033
+ buf = buf.slice(idx + 2)
1034
+ if (/(^|\n)event:\s*wake/.test(frame)) drainCommands(cfg)
1035
+ }
1036
+ }
1037
+ } catch (err) {
1038
+ log(`… command stream disconnected (${err.message}); reconnecting`)
1039
+ }
1040
+ await new Promise((r) => setTimeout(r, STREAM_RECONNECT_MS))
1041
+ }
1042
+ }
1043
+
853
1044
  // ─── Main ────────────────────────────────────────────────────────────────────
854
1045
 
855
1046
  async function runLoop(cfg) {
@@ -871,6 +1062,10 @@ async function runLoop(cfg) {
871
1062
  }
872
1063
  }
873
1064
 
1065
+ // Fast command path runs alongside the snapshot poll. Fire-and-forget: it owns
1066
+ // its own reconnect loop and never rejects.
1067
+ streamCommands(cfg)
1068
+
874
1069
  await tick()
875
1070
  setInterval(tick, cfg.interval * 1000)
876
1071
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
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": {