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.
- package/index.js +205 -10
- 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.
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
}
|