gitdone-agent 0.7.4 → 0.7.5

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 +61 -9
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  } from 'node:fs'
17
17
  import { resolve, join } from 'node:path'
18
18
  import { homedir, hostname, tmpdir } from 'node:os'
19
- import { randomUUID } from 'node:crypto'
19
+ import { randomUUID, createHash } from 'node:crypto'
20
20
 
21
21
  // ─── Stable agent dir, config + logging ────────────────────────────────────────
22
22
  // Everything persistent lives here: a stable copy of the agent script (so
@@ -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.4'
30
+ const AGENT_VERSION = '0.7.5'
31
31
 
32
32
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
33
33
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -1834,22 +1834,69 @@ async function readClaudeUsage() {
1834
1834
  }
1835
1835
  }
1836
1836
 
1837
+ // The discovered repo list barely ever changes, but re-upserting every repo's
1838
+ // row on the server each tick is pure idle write load at scale. So we ship the
1839
+ // full `repos` list only when the discovered set actually changed, or every
1840
+ // SYNC_FULL_TICKS as a reconciliation (so a repo the user "forgot" server-side
1841
+ // reappears within ~10 min, and a fresh row is recreated if one went missing).
1842
+ // In between we send a liveness-only sync (no repos) — the server still returns
1843
+ // the tracked list from the DB, so nothing downstream notices (gd-459).
1844
+ const SYNC_FULL_TICKS = 20
1845
+ let syncRepoSig = null
1846
+ let syncFullCountdown = 0
1847
+
1837
1848
  async function sync(cfg, discovered) {
1838
1849
  const usage = await readClaudeUsage()
1850
+ const sig = createHash('sha1').update(JSON.stringify(discovered)).digest('hex')
1851
+ const full = sig !== syncRepoSig || syncFullCountdown <= 0
1839
1852
  const data = await api(cfg, '/api/v1/agent/sync', {
1840
1853
  machineId: cfg.machineId,
1841
1854
  hostname: cfg.hostname,
1842
1855
  agentVersion: AGENT_VERSION,
1843
1856
  roots: cfg.roots,
1844
- repos: discovered,
1857
+ repos: full ? discovered : [],
1845
1858
  ...(usage ? { usage } : {}),
1846
1859
  })
1860
+ if (full) { syncRepoSig = sig; syncFullCountdown = SYNC_FULL_TICKS }
1861
+ else syncFullCountdown--
1847
1862
  return data.tracked ?? []
1848
1863
  }
1849
1864
 
1850
- // Push one tracked repo's snapshot and run any pending commands for it.
1851
- async function pushSnapshot(cfg, repo) {
1865
+ // Per-repo cache of the last snapshot we actually SENT, so an idle repo whose
1866
+ // git state hasn't moved doesn't re-POST an identical snapshot every 30s tick
1867
+ // (gd-457). At scale that unchanged-snapshot flood is the dominant idle load on
1868
+ // the server; skipping it drops idle traffic by ~an order of magnitude. We still
1869
+ // resend at least every SNAPSHOT_HEARTBEAT_TICKS to refresh the repo's
1870
+ // lastSeenAt (used only for ordering — no tight online threshold reads it) and
1871
+ // as a last-ditch command-drain backstop. Kept long: the SSE stream is the fast
1872
+ // path and it reconnects every ~5 min (re-waking a drain), so the snapshot only
1873
+ // has to backstop the pathological "SSE totally dead" case (gd-459).
1874
+ const SNAPSHOT_HEARTBEAT_TICKS = 40
1875
+ const snapshotSigCache = new Map() // path → { sig, skipped }
1876
+
1877
+ // Cheap content signature of everything the server persists for a repo. Includes
1878
+ // diffs, so an edit to a working-tree file (which leaves modified/staged the
1879
+ // same but changes content) still counts as a change and is re-sent.
1880
+ function snapshotSignature(snapshot) {
1881
+ return createHash('sha1').update(JSON.stringify(snapshot)).digest('hex')
1882
+ }
1883
+
1884
+ // Push one tracked repo's snapshot and run any pending commands for it. `force`
1885
+ // bypasses the unchanged-skip — used right after a command mutates git state, so
1886
+ // the UI reflects it instantly regardless of the signature cache.
1887
+ async function pushSnapshot(cfg, repo, force = false) {
1852
1888
  const snapshot = getSnapshot(repo.path)
1889
+
1890
+ // Skip the network POST (and all the server-side DB work it triggers) when the
1891
+ // repo is byte-for-byte unchanged since we last sent it — unless we're due a
1892
+ // heartbeat resend or the caller forced it (gd-457).
1893
+ const sig = snapshotSignature(snapshot)
1894
+ const cached = snapshotSigCache.get(repo.path)
1895
+ if (!force && cached && cached.sig === sig && cached.skipped < SNAPSHOT_HEARTBEAT_TICKS) {
1896
+ cached.skipped++
1897
+ return false // unchanged — nothing sent
1898
+ }
1899
+
1853
1900
  const data = await api(cfg, '/api/v1/agent/snapshot', {
1854
1901
  machineId: cfg.machineId,
1855
1902
  path: repo.path,
@@ -1859,6 +1906,10 @@ async function pushSnapshot(cfg, repo) {
1859
1906
  hasGithubAuth: !!cfg.auth?.[repo.path],
1860
1907
  ...snapshot,
1861
1908
  })
1909
+ // Sent successfully — remember this signature so identical follow-up ticks are
1910
+ // skipped until the next real change or heartbeat (gd-457). A thrown POST never
1911
+ // reaches here, so a failure just retries next tick.
1912
+ snapshotSigCache.set(repo.path, { sig, skipped: 0 })
1862
1913
  // Server issued a (fresh) push token — cache it on disk so we ask only once.
1863
1914
  if (data.githubAuth?.token && data.githubAuth?.repo) {
1864
1915
  cfg.auth = cfg.auth ?? {}
@@ -1868,7 +1919,7 @@ async function pushSnapshot(cfg, repo) {
1868
1919
  for (const cmd of data.commands ?? []) {
1869
1920
  await executeCommand(cfg, cmd, repo.path)
1870
1921
  }
1871
- return snapshot
1922
+ return true // sent
1872
1923
  }
1873
1924
 
1874
1925
  // ─── Low-latency command channel (gd-274) ───────────────────────────────────────
@@ -1921,7 +1972,7 @@ async function drainCommands(cfg) {
1921
1972
  if (GIT_STATE_CMDS.has(cmd.type)) touched.set(repoPath, cmd.repoName || repoPath)
1922
1973
  }
1923
1974
  for (const [path, name] of touched) {
1924
- try { await pushSnapshot(cfg, { path, name }) }
1975
+ try { await pushSnapshot(cfg, { path, name }, true) }
1925
1976
  catch (err) { log(`✗ post-command snapshot failed @ ${path}: ${err.message}`) }
1926
1977
  }
1927
1978
  } while (drainAgain)
@@ -1981,11 +2032,12 @@ async function runLoop(cfg) {
1981
2032
  const discovered = scanRepos(cfg.roots)
1982
2033
  const tracked = await sync(cfg, discovered)
1983
2034
  let pushed = 0
2035
+ let skipped = 0
1984
2036
  for (const repo of tracked) {
1985
- try { await pushSnapshot(cfg, repo); pushed++ }
2037
+ try { (await pushSnapshot(cfg, repo)) ? pushed++ : skipped++ }
1986
2038
  catch (err) { log(`✗ snapshot failed @ ${repo.path}: ${err.message}`) }
1987
2039
  }
1988
- log(`✓ tick — discovered: ${discovered.length}, tracked: ${tracked.length}, pushed: ${pushed}`)
2040
+ log(`✓ tick — discovered: ${discovered.length}, tracked: ${tracked.length}, pushed: ${pushed}, unchanged: ${skipped}`)
1989
2041
  } catch (err) {
1990
2042
  log(`✗ sync error: ${err.message}`)
1991
2043
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
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": {