gitdone-agent 0.5.6 → 0.6.2
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 +268 -22
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
import { execSync, spawn } from 'node:child_process'
|
|
13
13
|
import {
|
|
14
14
|
existsSync, writeFileSync, readFileSync, unlinkSync,
|
|
15
|
-
mkdirSync, copyFileSync, appendFileSync, readdirSync,
|
|
15
|
+
mkdirSync, copyFileSync, appendFileSync, readdirSync, rmSync,
|
|
16
16
|
} from 'node:fs'
|
|
17
17
|
import { resolve, join } from 'node:path'
|
|
18
|
-
import { homedir, hostname } from 'node:os'
|
|
18
|
+
import { homedir, hostname, tmpdir } from 'node:os'
|
|
19
19
|
import { randomUUID } from 'node:crypto'
|
|
20
20
|
|
|
21
21
|
// ─── Stable agent dir, config + logging ────────────────────────────────────────
|
|
@@ -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.2'
|
|
31
31
|
|
|
32
32
|
const AGENT_DIR = join(homedir(), '.gitdone-agent')
|
|
33
33
|
const CONFIG_PATH = join(AGENT_DIR, 'config.json')
|
|
@@ -239,6 +239,18 @@ function git(cmd, cwd) {
|
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// Like git(), but returns the RAW bytes instead of a UTF-8 string. Needed for
|
|
243
|
+
// `git diff`, whose payload can be Windows-1251 (CP1251) Cyrillic — decoding
|
|
244
|
+
// those bytes as UTF-8 up front would corrupt them to „�"/„?" irreversibly
|
|
245
|
+
// (gd-276). Callers decode per-file via decodeDiffText().
|
|
246
|
+
function gitRaw(cmd, cwd) {
|
|
247
|
+
try {
|
|
248
|
+
return execSync(cmd, { cwd, encoding: 'buffer', stdio: ['pipe', 'pipe', 'pipe'] })
|
|
249
|
+
} catch {
|
|
250
|
+
return Buffer.alloc(0)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
242
254
|
// Like git(), but surfaces failures (with stderr) instead of swallowing them —
|
|
243
255
|
// used for push/pull where we need to detect auth rejection.
|
|
244
256
|
function gitTry(cmd, cwd) {
|
|
@@ -264,20 +276,71 @@ function authUrl(auth) {
|
|
|
264
276
|
return `https://x-access-token:${auth.token}@github.com/${repo}.git`
|
|
265
277
|
}
|
|
266
278
|
|
|
279
|
+
// A push rejected because the remote branch moved ahead of us (someone else
|
|
280
|
+
// pushed in the meantime). Git prints "non-fast-forward" / "fetch first" /
|
|
281
|
+
// "tip of your current branch is behind". We recover by pulling+rebasing.
|
|
282
|
+
const NON_FAST_FORWARD = /non-fast-forward|fetch first|tip of your current branch is behind|failed to push some refs/i
|
|
283
|
+
|
|
284
|
+
// Point the local remote-tracking ref (e.g. refs/remotes/origin/master) at the
|
|
285
|
+
// commit we just synced. We push/pull through the ad-hoc token URL, and — unlike
|
|
286
|
+
// `git push origin` — pushing/pulling by URL leaves refs/remotes/origin/* UNTOUCHED.
|
|
287
|
+
// getSnapshot() then measures "ahead"/"behind" against that stale tracking ref
|
|
288
|
+
// (git rev-list @{u}..HEAD), so already-pushed commits keep showing as pending
|
|
289
|
+
// forever: the ahead badge never clears, GitHub Desktop shows the same "N↑", and
|
|
290
|
+
// the user pushes again and again while local commits pile up (gd-273). Fast-
|
|
291
|
+
// forwarding the tracking ref to the true remote tip resets ahead/behind to reality.
|
|
292
|
+
// Best-effort: git() swallows failures so this never breaks the push/pull itself.
|
|
293
|
+
function updateTrackingRef(repoPath, branch, commitish) {
|
|
294
|
+
// Prefer the branch's configured upstream (usually origin/<branch>); fall back
|
|
295
|
+
// to origin/<branch> when no upstream is set.
|
|
296
|
+
const upstream = git('git rev-parse --abbrev-ref --symbolic-full-name @{u}', repoPath)
|
|
297
|
+
const ref = upstream ? `refs/remotes/${upstream}` : `refs/remotes/origin/${branch}`
|
|
298
|
+
git(`git update-ref "${ref}" ${commitish}`, repoPath)
|
|
299
|
+
}
|
|
300
|
+
|
|
267
301
|
// Push/pull using the server-issued token when we have one; otherwise fall back
|
|
268
302
|
// to the machine's own git credentials (old behaviour). Throws on failure.
|
|
269
303
|
function pushOrPull(type, repoPath, auth) {
|
|
270
304
|
const branch = git('git branch --show-current', repoPath) || 'HEAD'
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
305
|
+
const url = auth ? authUrl(auth) : null
|
|
306
|
+
const pushCmd = url ? `git push "${url}" HEAD:${branch}` : 'git push'
|
|
307
|
+
|
|
308
|
+
if (type === 'pull') {
|
|
309
|
+
const r = gitTry(url ? `git pull "${url}" ${branch}` : 'git pull', repoPath)
|
|
310
|
+
if (!r.ok) throw new Error(r.out || 'pull failed')
|
|
311
|
+
// Pulling by URL never advanced origin/<branch>; point it at the fetched tip
|
|
312
|
+
// (FETCH_HEAD) so a later push isn't seen as being "ahead" of a stale ref.
|
|
313
|
+
if (url) updateTrackingRef(repoPath, branch, 'FETCH_HEAD')
|
|
314
|
+
return r.out || 'pulled'
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let r = gitTry(pushCmd, repoPath)
|
|
318
|
+
if (r.ok) {
|
|
319
|
+
// We just pushed HEAD to the remote branch, so the remote tip == HEAD. Sync
|
|
320
|
+
// the local tracking ref to clear the "ahead" count (gd-273).
|
|
321
|
+
if (url) updateTrackingRef(repoPath, branch, 'HEAD')
|
|
322
|
+
return r.out || 'pushed'
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Push rejected because the remote is ahead (gd-272): commit succeeded but the
|
|
326
|
+
// push bounced with non-fast-forward, so the files looked "pushed" while the
|
|
327
|
+
// UI showed a scary git error. Pull with --rebase to replay our commits on top
|
|
328
|
+
// of the remote ones, then push again — what the user means by "push my files".
|
|
329
|
+
if (NON_FAST_FORWARD.test(r.out)) {
|
|
330
|
+
const pull = gitTry(url ? `git pull --rebase "${url}" ${branch}` : 'git pull --rebase', repoPath)
|
|
331
|
+
if (!pull.ok) {
|
|
332
|
+
// Rebase couldn't apply cleanly (conflicts) — abort so the repo isn't left
|
|
333
|
+
// mid-rebase, and surface an actionable message instead of guessing.
|
|
334
|
+
gitTry('git rebase --abort', repoPath)
|
|
335
|
+
throw new Error(`отдалеченият клон е напред и има конфликт при обединяване — дръпни (Sync) и слей ръчно, после пусни пак.\n${pull.out}`)
|
|
336
|
+
}
|
|
337
|
+
r = gitTry(pushCmd, repoPath)
|
|
338
|
+
if (!r.ok) throw new Error(r.out || 'push failed')
|
|
339
|
+
if (url) updateTrackingRef(repoPath, branch, 'HEAD')
|
|
340
|
+
return `дръпнах новите промени от сървъра и пушнах наново.\n${r.out}`.trim()
|
|
277
341
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
return r.out || (type === 'push' ? 'pushed' : 'pulled')
|
|
342
|
+
|
|
343
|
+
throw new Error(r.out || 'push failed')
|
|
281
344
|
}
|
|
282
345
|
|
|
283
346
|
const AUTH_FAIL = /authentication|authorization|403|401|denied|could not read Username|invalid username or password|terminal prompts disabled/i
|
|
@@ -303,14 +366,57 @@ function discardFile(repoPath, file) {
|
|
|
303
366
|
return 'discarded (new file removed)'
|
|
304
367
|
}
|
|
305
368
|
|
|
306
|
-
|
|
369
|
+
// ─── Windows-1251 (CP1251) aware diff decoding (gd-276) ───────────────────────
|
|
370
|
+
// Legacy Cyrillic sources are often saved in CP1251. Their high bytes (0x80–0xFF,
|
|
371
|
+
// single-byte) are invalid UTF-8, so reading the diff as UTF-8 shows „?"/„�"
|
|
372
|
+
// instead of кирилица. We keep the raw bytes and decode each file's diff section
|
|
373
|
+
// on its own: valid UTF-8 stays UTF-8; the rest goes through the CP1251 table
|
|
374
|
+
// below — so a UTF-8 repo is untouched while CP1251 files finally read correctly.
|
|
375
|
+
|
|
376
|
+
// CP1251 high range 0x80–0xFF → Unicode code points (0x00–0x7F is plain ASCII).
|
|
377
|
+
// 0x98 is unassigned in CP1251 → U+FFFD.
|
|
378
|
+
const CP1251_HIGH = [
|
|
379
|
+
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F,
|
|
380
|
+
0x0452, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F,
|
|
381
|
+
0x00A0, 0x040E, 0x045E, 0x0408, 0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407,
|
|
382
|
+
0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5, 0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457,
|
|
383
|
+
0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417, 0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F,
|
|
384
|
+
0x0420, 0x0421, 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429, 0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F,
|
|
385
|
+
0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B, 0x043C, 0x043D, 0x043E, 0x043F,
|
|
386
|
+
0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D, 0x044E, 0x044F,
|
|
387
|
+
]
|
|
388
|
+
|
|
389
|
+
function decodeCp1251(buf) {
|
|
390
|
+
let out = ''
|
|
391
|
+
for (const b of buf) out += String.fromCharCode(b < 0x80 ? b : CP1251_HIGH[b - 0x80])
|
|
392
|
+
return out
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// A strict UTF-8 decoder (always available even on small-ICU builds) — throws on
|
|
396
|
+
// any invalid sequence, which is exactly how we tell UTF-8 apart from CP1251.
|
|
397
|
+
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true })
|
|
398
|
+
function decodeDiffText(buf) {
|
|
399
|
+
try {
|
|
400
|
+
return UTF8_STRICT.decode(buf)
|
|
401
|
+
} catch {
|
|
402
|
+
return decodeCp1251(buf)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function parseDiffByFile(diffBuf) {
|
|
307
407
|
const files = {}
|
|
308
|
-
if (!
|
|
309
|
-
|
|
408
|
+
if (!diffBuf || diffBuf.length === 0) return files
|
|
409
|
+
// Latin1 is a lossless 1-byte⇢1-char mapping, so the ASCII "diff --git" split
|
|
410
|
+
// markers match while every original byte survives for the per-section decode.
|
|
411
|
+
const raw = Buffer.isBuffer(diffBuf) ? diffBuf.toString('latin1') : String(diffBuf)
|
|
412
|
+
const sections = raw.split(/(?=^diff --git )/m)
|
|
310
413
|
for (const section of sections) {
|
|
311
414
|
if (!section.trim()) continue
|
|
312
|
-
|
|
313
|
-
|
|
415
|
+
// Decode THIS file's bytes on their own (UTF-8 or CP1251), then read the
|
|
416
|
+
// filename from the decoded header.
|
|
417
|
+
const text = decodeDiffText(Buffer.from(section, 'latin1'))
|
|
418
|
+
const match = text.match(/^diff --git a\/.+ b\/(.+)$/m)
|
|
419
|
+
if (match) files[match[1]] = text
|
|
314
420
|
}
|
|
315
421
|
return files
|
|
316
422
|
}
|
|
@@ -340,7 +446,7 @@ function getSnapshot(repoPath) {
|
|
|
340
446
|
lastCommit = { sha, message, author, date }
|
|
341
447
|
}
|
|
342
448
|
|
|
343
|
-
const diffs = parseDiffByFile(
|
|
449
|
+
const diffs = parseDiffByFile(gitRaw('git diff HEAD', repoPath))
|
|
344
450
|
|
|
345
451
|
// Origin remote → lets the server auto-detect the GitHub repo for the History tab.
|
|
346
452
|
const remoteUrl = git('git config --get remote.origin.url', repoPath) || null
|
|
@@ -660,15 +766,42 @@ function runAiCommand(cfg, cmd, repoPath) {
|
|
|
660
766
|
})
|
|
661
767
|
}
|
|
662
768
|
|
|
769
|
+
// Download the images attached to a chat turn to a per-session temp dir and
|
|
770
|
+
// return their local paths. `claude -p` can't take image URLs on stdin, so we
|
|
771
|
+
// fetch them locally and point Claude at the files (its Read tool renders
|
|
772
|
+
// images). Best-effort: a failed download is skipped, not fatal.
|
|
773
|
+
async function downloadSessionImages(sessionId, urls) {
|
|
774
|
+
const dir = join(tmpdir(), `gitdone-ai-${sessionId}`)
|
|
775
|
+
mkdirSync(dir, { recursive: true })
|
|
776
|
+
const paths = []
|
|
777
|
+
for (let i = 0; i < urls.length; i++) {
|
|
778
|
+
try {
|
|
779
|
+
const res = await fetch(urls[i])
|
|
780
|
+
if (!res.ok) { log(`✗ image download HTTP ${res.status}: ${urls[i]}`); continue }
|
|
781
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
782
|
+
const m = /\.(png|jpe?g|gif|webp|bmp)(?:\?|$)/i.exec(urls[i])
|
|
783
|
+
const ext = m ? m[1].toLowerCase() : 'png'
|
|
784
|
+
const file = join(dir, `image-${i + 1}.${ext}`)
|
|
785
|
+
writeFileSync(file, buf)
|
|
786
|
+
paths.push(file)
|
|
787
|
+
} catch (e) {
|
|
788
|
+
log(`✗ image download failed (${urls[i]}): ${e.message}`)
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return { dir, paths }
|
|
792
|
+
}
|
|
793
|
+
|
|
663
794
|
// Run one turn of an interactive chat session: `claude -p` (resuming the prior
|
|
664
795
|
// Claude session when known) with the user's message on stdin, streaming the
|
|
665
796
|
// reply back to the session console. Long-running + fire-and-forget like ai_run.
|
|
666
|
-
function runAiChat(cfg, cmd, repoPath) {
|
|
797
|
+
async function runAiChat(cfg, cmd, repoPath) {
|
|
667
798
|
const sessionId = cmd.payload?.sessionId
|
|
668
799
|
const prompt = cmd.payload?.prompt ?? ''
|
|
800
|
+
const images = Array.isArray(cmd.payload?.images) ? cmd.payload.images.filter((u) => typeof u === 'string' && u) : []
|
|
669
801
|
const claudeSessionId = cmd.payload?.claudeSessionId || null
|
|
670
802
|
const allowCommit = cmd.payload?.allowCommit === true
|
|
671
|
-
|
|
803
|
+
// A turn may be text-only, image-only, or both — but needs at least one.
|
|
804
|
+
if (!sessionId || (!prompt && images.length === 0)) {
|
|
672
805
|
reportCommandResult(cfg, cmd.id, 'error', 'invalid ai_chat payload')
|
|
673
806
|
return
|
|
674
807
|
}
|
|
@@ -715,13 +848,28 @@ function runAiChat(cfg, cmd, repoPath) {
|
|
|
715
848
|
const onInit = (sid) => { if (sid && !claudeSessionId) capturedSession = sid }
|
|
716
849
|
const timer = setInterval(flush, 800)
|
|
717
850
|
|
|
718
|
-
|
|
851
|
+
// Fetch any attached images locally and fold their paths into the prompt so
|
|
852
|
+
// Claude reads them with its Read tool (URLs on stdin don't render).
|
|
853
|
+
let imgDir = null
|
|
854
|
+
let fullPrompt = prompt
|
|
855
|
+
if (images.length > 0) {
|
|
856
|
+
const dl = await downloadSessionImages(sessionId, images)
|
|
857
|
+
imgDir = dl.dir
|
|
858
|
+
if (dl.paths.length > 0) {
|
|
859
|
+
const list = dl.paths.map((p) => `- ${p}`).join('\n')
|
|
860
|
+
const note = `Потребителят прикачи ${dl.paths.length} изображени${dl.paths.length === 1 ? 'е' : 'я'}. Прегледай ги с Read tool от следните локални пътища:\n${list}`
|
|
861
|
+
fullPrompt = prompt ? `${prompt}\n\n[${note}]` : `[${note}]`
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
log(`▶ ai_chat ${sessionId} @ ${repoPath} (resume=${claudeSessionId ? 'yes' : 'no'}, images=${images.length}) via ${claudePath}`)
|
|
719
866
|
|
|
720
867
|
let child
|
|
721
868
|
try {
|
|
722
869
|
child = spawn(claudePath, args, { cwd: repoPath, shell, windowsHide: true, env: childEnv })
|
|
723
870
|
} catch (err) {
|
|
724
871
|
clearInterval(timer)
|
|
872
|
+
if (imgDir) { try { rmSync(imgDir, { recursive: true, force: true }) } catch { /* best-effort */ } }
|
|
725
873
|
postSessionEvents(cfg, sessionId, [{ role: 'SYSTEM', text: `Грешка при стартиране: ${err.message}` }], 'error')
|
|
726
874
|
reportCommandResult(cfg, cmd.id, 'error', err.message)
|
|
727
875
|
return
|
|
@@ -729,7 +877,7 @@ function runAiChat(cfg, cmd, repoPath) {
|
|
|
729
877
|
|
|
730
878
|
if (child.stdin) {
|
|
731
879
|
child.stdin.on('error', () => {})
|
|
732
|
-
try { child.stdin.write(
|
|
880
|
+
try { child.stdin.write(fullPrompt); child.stdin.end() }
|
|
733
881
|
catch (e) { push('SYSTEM', `stdin write failed: ${e.message}`) }
|
|
734
882
|
}
|
|
735
883
|
|
|
@@ -747,6 +895,7 @@ function runAiChat(cfg, cmd, repoPath) {
|
|
|
747
895
|
child.on('error', (err) => push('SYSTEM', `Процесна грешка: ${err.message}`))
|
|
748
896
|
child.on('close', async (code) => {
|
|
749
897
|
clearInterval(timer)
|
|
898
|
+
if (imgDir) { try { rmSync(imgDir, { recursive: true, force: true }) } catch { /* best-effort */ } }
|
|
750
899
|
if (buf.trim()) parseStreamLine(buf.trim(), push, onInit)
|
|
751
900
|
await flush()
|
|
752
901
|
const ok = code === 0
|
|
@@ -897,6 +1046,99 @@ async function pushSnapshot(cfg, repo) {
|
|
|
897
1046
|
return snapshot
|
|
898
1047
|
}
|
|
899
1048
|
|
|
1049
|
+
// ─── Low-latency command channel (gd-274) ───────────────────────────────────────
|
|
1050
|
+
// The 30s snapshot poll is fine for reflecting git state, but makes user actions
|
|
1051
|
+
// (Push/Pull/Commit/АИ) feel sluggish: a queued command waits up to a full tick
|
|
1052
|
+
// before the agent even asks for it. So we ALSO hold a persistent SSE connection
|
|
1053
|
+
// to the server and drain+run commands the instant it says "wake". The snapshot
|
|
1054
|
+
// poll stays as the reliable fallback (and cron heartbeat) if the stream drops.
|
|
1055
|
+
|
|
1056
|
+
const GIT_STATE_CMDS = new Set(['commit', 'push', 'pull', 'discard'])
|
|
1057
|
+
|
|
1058
|
+
// Pull this machine's pending commands in one shot (no git/snapshot work server-
|
|
1059
|
+
// side) and run them. Serialised via a tiny mutex so overlapping wakes don't
|
|
1060
|
+
// double-drain; a wake arriving mid-drain sets a flag to run once more after.
|
|
1061
|
+
let draining = false
|
|
1062
|
+
let drainAgain = false
|
|
1063
|
+
async function drainCommands(cfg) {
|
|
1064
|
+
if (draining) { drainAgain = true; return }
|
|
1065
|
+
draining = true
|
|
1066
|
+
try {
|
|
1067
|
+
do {
|
|
1068
|
+
drainAgain = false
|
|
1069
|
+
let data
|
|
1070
|
+
try {
|
|
1071
|
+
data = await api(cfg, '/api/v1/agent/commands', { machineId: cfg.machineId })
|
|
1072
|
+
} catch (err) {
|
|
1073
|
+
log(`✗ command drain failed: ${err.message}`)
|
|
1074
|
+
return
|
|
1075
|
+
}
|
|
1076
|
+
const commands = data.commands ?? []
|
|
1077
|
+
if (commands.length === 0) continue
|
|
1078
|
+
|
|
1079
|
+
// Cache any push tokens the server issued for these commands' repos.
|
|
1080
|
+
if (data.githubAuth && typeof data.githubAuth === 'object') {
|
|
1081
|
+
cfg.auth = cfg.auth ?? {}
|
|
1082
|
+
for (const [path, a] of Object.entries(data.githubAuth)) {
|
|
1083
|
+
if (a && a.token && a.repo) cfg.auth[path] = a
|
|
1084
|
+
}
|
|
1085
|
+
writeConfig(cfg)
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Run each command against its own repo path, and remember which repos had
|
|
1089
|
+
// their git state changed so we can push a fresh snapshot right after —
|
|
1090
|
+
// that's what makes the UI update instantly instead of on the next tick.
|
|
1091
|
+
const touched = new Map()
|
|
1092
|
+
for (const cmd of commands) {
|
|
1093
|
+
const repoPath = cmd.path
|
|
1094
|
+
if (!repoPath) { log(`✗ command ${cmd.id} has no path — skipped`); continue }
|
|
1095
|
+
await executeCommand(cfg, cmd, repoPath)
|
|
1096
|
+
if (GIT_STATE_CMDS.has(cmd.type)) touched.set(repoPath, cmd.repoName || repoPath)
|
|
1097
|
+
}
|
|
1098
|
+
for (const [path, name] of touched) {
|
|
1099
|
+
try { await pushSnapshot(cfg, { path, name }) }
|
|
1100
|
+
catch (err) { log(`✗ post-command snapshot failed @ ${path}: ${err.message}`) }
|
|
1101
|
+
}
|
|
1102
|
+
} while (drainAgain)
|
|
1103
|
+
} finally {
|
|
1104
|
+
draining = false
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Hold a persistent SSE connection open and drain the moment the server pushes a
|
|
1109
|
+
// `wake`. Reconnects forever with a short backoff; the server also recycles the
|
|
1110
|
+
// connection every few minutes (each reconnect re-sends an initial wake, so
|
|
1111
|
+
// anything queued while we were away is picked up). Never throws.
|
|
1112
|
+
const STREAM_RECONNECT_MS = 3000
|
|
1113
|
+
async function streamCommands(cfg) {
|
|
1114
|
+
const url = `${cfg.url}/api/v1/agent/commands/stream?machineId=${encodeURIComponent(cfg.machineId)}`
|
|
1115
|
+
for (;;) {
|
|
1116
|
+
try {
|
|
1117
|
+
const res = await fetch(url, { headers: { Authorization: `Bearer ${cfg.key}` } })
|
|
1118
|
+
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
|
|
1119
|
+
log('▶ command stream connected')
|
|
1120
|
+
const reader = res.body.getReader()
|
|
1121
|
+
const decoder = new TextDecoder()
|
|
1122
|
+
let buf = ''
|
|
1123
|
+
for (;;) {
|
|
1124
|
+
const { value, done } = await reader.read()
|
|
1125
|
+
if (done) break
|
|
1126
|
+
buf += decoder.decode(value, { stream: true })
|
|
1127
|
+
// SSE events are separated by a blank line; a `wake` means "drain now".
|
|
1128
|
+
let idx
|
|
1129
|
+
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
1130
|
+
const frame = buf.slice(0, idx)
|
|
1131
|
+
buf = buf.slice(idx + 2)
|
|
1132
|
+
if (/(^|\n)event:\s*wake/.test(frame)) drainCommands(cfg)
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
log(`… command stream disconnected (${err.message}); reconnecting`)
|
|
1137
|
+
}
|
|
1138
|
+
await new Promise((r) => setTimeout(r, STREAM_RECONNECT_MS))
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
900
1142
|
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
901
1143
|
|
|
902
1144
|
async function runLoop(cfg) {
|
|
@@ -918,6 +1160,10 @@ async function runLoop(cfg) {
|
|
|
918
1160
|
}
|
|
919
1161
|
}
|
|
920
1162
|
|
|
1163
|
+
// Fast command path runs alongside the snapshot poll. Fire-and-forget: it owns
|
|
1164
|
+
// its own reconnect loop and never rejects.
|
|
1165
|
+
streamCommands(cfg)
|
|
1166
|
+
|
|
921
1167
|
await tick()
|
|
922
1168
|
setInterval(tick, cfg.interval * 1000)
|
|
923
1169
|
}
|