@shardflux/cli 0.19.1 → 0.22.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 (115) hide show
  1. package/CHANGELOG.md +278 -1
  2. package/README.md +225 -73
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +20 -15
  6. package/dist/account-pool.js +51 -97
  7. package/dist/account-scope.d.ts +65 -0
  8. package/dist/account-scope.js +246 -0
  9. package/dist/acp-login.d.ts +4 -3
  10. package/dist/acp-login.js +7 -5
  11. package/dist/acp-models.js +20 -15
  12. package/dist/acp.js +66 -13
  13. package/dist/agent-commands.d.ts +4 -1
  14. package/dist/agent-commands.js +86 -32
  15. package/dist/agent-config-files.d.ts +19 -0
  16. package/dist/agent-config-files.js +70 -1
  17. package/dist/agent-config-return.d.ts +194 -0
  18. package/dist/agent-config-return.js +724 -0
  19. package/dist/agent-config-sync.d.ts +10 -2
  20. package/dist/agent-config-sync.js +45 -51
  21. package/dist/agent-credentials.js +7 -3
  22. package/dist/agent-repository.d.ts +16 -3
  23. package/dist/agent-repository.js +149 -74
  24. package/dist/agent-setup.d.ts +14 -3
  25. package/dist/agent-setup.js +78 -36
  26. package/dist/agent-target-commands.js +24 -13
  27. package/dist/args.d.ts +1 -1
  28. package/dist/args.js +104 -55
  29. package/dist/bin.js +5 -1
  30. package/dist/browser.d.ts +13 -2
  31. package/dist/browser.js +30 -14
  32. package/dist/claude-cloud-login.d.ts +24 -2
  33. package/dist/claude-cloud-login.js +151 -11
  34. package/dist/cli-proxy-forward.d.ts +2 -0
  35. package/dist/cli-proxy-forward.js +9 -2
  36. package/dist/cli-proxy-hub.d.ts +69 -25
  37. package/dist/cli-proxy-hub.js +231 -54
  38. package/dist/cli-proxy.d.ts +70 -1
  39. package/dist/cli-proxy.js +298 -32
  40. package/dist/client-settings.d.ts +39 -0
  41. package/dist/client-settings.js +91 -0
  42. package/dist/codex-cloud.d.ts +5 -0
  43. package/dist/codex-cloud.js +88 -17
  44. package/dist/command-center-actions.js +5 -1
  45. package/dist/commands.js +171 -22
  46. package/dist/conversation-commands.d.ts +5 -0
  47. package/dist/conversation-commands.js +8 -5
  48. package/dist/conversation-terminal.d.ts +10 -2
  49. package/dist/conversation-terminal.js +81 -15
  50. package/dist/conversations.d.ts +77 -1
  51. package/dist/conversations.js +257 -23
  52. package/dist/device-bridge.d.ts +1 -1
  53. package/dist/device-bridge.js +6 -4
  54. package/dist/device-helper.js +52 -4
  55. package/dist/errors.d.ts +34 -1
  56. package/dist/errors.js +56 -5
  57. package/dist/filesystem-commands.d.ts +22 -3
  58. package/dist/filesystem-commands.js +132 -16
  59. package/dist/format.d.ts +2 -1
  60. package/dist/format.js +10 -1
  61. package/dist/handlers.js +4 -1
  62. package/dist/initial-prompt.d.ts +8 -0
  63. package/dist/initial-prompt.js +43 -1
  64. package/dist/large-media.d.ts +14 -0
  65. package/dist/large-media.js +100 -0
  66. package/dist/linux-terminal.d.ts +5 -3
  67. package/dist/linux-terminal.js +6 -20
  68. package/dist/local-sessions.d.ts +26 -2
  69. package/dist/local-sessions.js +113 -24
  70. package/dist/machine-setup.d.ts +190 -0
  71. package/dist/machine-setup.js +849 -0
  72. package/dist/machine-status.d.ts +67 -0
  73. package/dist/machine-status.js +140 -0
  74. package/dist/main.js +23 -13
  75. package/dist/move-worktrees-restore.py +144 -0
  76. package/dist/move-worktrees.d.ts +63 -0
  77. package/dist/move-worktrees.js +201 -0
  78. package/dist/move.d.ts +10 -1
  79. package/dist/move.js +116 -16
  80. package/dist/onboarding.d.ts +11 -7
  81. package/dist/onboarding.js +59 -27
  82. package/dist/prepared-repository.d.ts +202 -0
  83. package/dist/prepared-repository.js +860 -0
  84. package/dist/prepared-repository.py +1306 -0
  85. package/dist/project-preparation.d.ts +48 -0
  86. package/dist/project-preparation.js +304 -0
  87. package/dist/project-sync-manifest.d.ts +4 -0
  88. package/dist/project-sync-manifest.js +5 -1
  89. package/dist/project-sync-transfer.js +1 -1
  90. package/dist/project-sync.d.ts +19 -4
  91. package/dist/project-sync.js +31 -8
  92. package/dist/project-template-setup.d.ts +33 -1
  93. package/dist/project-template-setup.js +59 -2
  94. package/dist/secret-ask.d.ts +72 -0
  95. package/dist/secret-ask.js +351 -0
  96. package/dist/seed-phases.d.ts +13 -3
  97. package/dist/seed-phases.js +21 -3
  98. package/dist/setup-cache.d.ts +7 -0
  99. package/dist/setup-cache.js +32 -0
  100. package/dist/t3-launch.js +1 -4
  101. package/dist/t3-mode.d.ts +8 -2
  102. package/dist/t3-mode.js +19 -4
  103. package/dist/t3-phone.d.ts +4 -2
  104. package/dist/t3-phone.js +12 -4
  105. package/dist/terminal-apps.d.ts +157 -0
  106. package/dist/terminal-apps.js +561 -0
  107. package/dist/terminal-onboarding.d.ts +2 -0
  108. package/dist/terminal-onboarding.js +11 -1
  109. package/dist/this-computer.d.ts +11 -0
  110. package/dist/this-computer.js +36 -0
  111. package/dist/thread-on-computer.js +4 -1
  112. package/dist/update.js +11 -3
  113. package/dist/worktree-twin.d.ts +10 -2
  114. package/dist/worktree-twin.js +27 -9
  115. package/package.json +5 -5
@@ -0,0 +1,1306 @@
1
+ """Prepared projects, the VM half: one exec per call, a JSON request on stdin, one JSON line on stdout.
2
+
3
+ The project cache (/shardflux/project/prepared/<remote hash>) is shared storage (EFS behind an NFS bridge): large
4
+ sequential I/O is fast, but every file lookup is a round trip, and one Git command there reads dozens of files (a
5
+ `git for-each-ref` on a project hub took 9.7 s cold). So nothing on the way to a first prompt runs Git against the
6
+ cache: the cache keeps small marker files (state.json, published/, ready/, media-index) that the probe reads, and a
7
+ start indexes what it uploads into the conversation's own repository on the VM's disk. Filling the cache (indexing
8
+ packs into its repository, publishing, bookkeeping) happens after the reply, in a detached child.
9
+
10
+ A revision names a snapshot S (the user's working state as a commit on their HEAD, worktree-twin.ts), the large
11
+ untracked media files beside it (Git blobs kept by a content ref, never in S), and a tar of what Git must not hold
12
+ (.env files, filter output). A conversation worktree is S checked out on the conversation branch, as the twin seed does,
13
+ so landing compares S..T; the media land as files its Git ignores, as the user's Git does, possibly after the prompt.
14
+
15
+ A worktree that holds another revision is never rewritten, unless it is a project template's checkout.
16
+ """
17
+ import errno
18
+ import fcntl
19
+ import hashlib
20
+ import json
21
+ import os
22
+ import re
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ import time
27
+ import uuid
28
+
29
+ HASH = re.compile(r"^[a-f0-9]{64}$")
30
+ OID = re.compile(r"^[a-f0-9]{40}(?:[a-f0-9]{24})?$")
31
+ BRANCH = re.compile(r"^shard/[a-z0-9][a-z0-9-]{0,47}$")
32
+ PARTS = re.compile(r"^/(?:[^/\0]+/)*prepared-parts/[a-f0-9]{16}$")
33
+ REVISION_TTL = 86400
34
+ KEEP_REVISIONS = 8
35
+ DEPENDENCY_DIRS = {"node_modules", ".venv", "venv", "vendor", "target", ".bundle", ".gradle"}
36
+ EXCLUDE_BEGIN, EXCLUDE_END = "# shardflux: large files outside the snapshot (begin)", "# shardflux: (end)"
37
+ STARTED = time.monotonic()
38
+ TIMINGS = {}
39
+ AFTER = [] # work that follows the reply: run in a detached child once the CLI has its answer
40
+ PRUNE_EVERY = 6 * 3600 # seconds between the passes a start runs after its reply
41
+ FULL_REPACK_EVERY = 7 * 86400 # a full repack (unreachable objects into a cruft pack) at most this often
42
+ PREFETCH_WAIT = 30 # seconds a start waits for the VM's own fetch of its branch (the copy seed's FETCH_ORIGIN waits as long)
43
+
44
+
45
+ def mark(name):
46
+ TIMINGS[name] = int((time.monotonic() - STARTED) * 1000)
47
+
48
+
49
+ class Refused(Exception):
50
+ """An expected refusal: reported as {"error": reason}, exit 0, so the CLI acts on the reason."""
51
+
52
+ def __init__(self, reason, message, **extra):
53
+ super().__init__(message)
54
+ self.reason, self.extra = reason, extra
55
+
56
+
57
+ def git(*argv, data=None, env=None, check=True):
58
+ p = subprocess.run(["git", "-c", "core.hooksPath=/dev/null", "-c", "gc.auto=0", *argv], input=data, stdout=subprocess.PIPE,
59
+ stderr=subprocess.PIPE, env=dict(os.environ, GIT_TERMINAL_PROMPT="0", **(env or {})))
60
+ if check and p.returncode != 0:
61
+ verb = next((a for a in argv if not a.startswith("-") and "=" not in a and "/" not in a), "")
62
+ raise RuntimeError("git %s: %s" % (verb, p.stderr.decode("utf-8", "replace").strip()[-1500:]))
63
+ return p
64
+
65
+
66
+ def out(*argv, **kw):
67
+ return git(*argv, **kw).stdout.decode().strip()
68
+
69
+
70
+ def atomic(path, data, mode=0o600):
71
+ os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
72
+ tmp = "%s.tmp-%s" % (path, uuid.uuid4().hex)
73
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode)
74
+ try:
75
+ with os.fdopen(fd, "wb") as f:
76
+ f.write(data)
77
+ f.flush()
78
+ os.fsync(f.fileno())
79
+ os.replace(tmp, path)
80
+ # An exclusive create over NFSv4 can leave the create verifier in the file's times (years ahead): set them.
81
+ now = time.time()
82
+ os.utime(path, (now, now))
83
+ except BaseException:
84
+ try:
85
+ os.unlink(tmp)
86
+ except FileNotFoundError:
87
+ pass
88
+ raise
89
+
90
+
91
+ def read_json(path, default=None):
92
+ try:
93
+ with open(path, "rb") as f:
94
+ return json.loads(f.read())
95
+ except (FileNotFoundError, NotADirectoryError, ValueError):
96
+ return default
97
+
98
+
99
+ def safe_path(p):
100
+ parts = p.split("/")
101
+ if not p or "\\" in p or "\0" in p or any(x in ("", ".", "..") for x in parts) or parts[0] in (".git", ".shard") or ".git" in parts:
102
+ raise ValueError("unsafe project path %r" % p)
103
+ return p
104
+
105
+
106
+ def valid_manifest(m):
107
+ if m.get("version") != 2 or m.get("format") not in ("sha1", "sha256") or not OID.fullmatch(m["head"]) or not OID.fullmatch(m["seed"]):
108
+ raise ValueError("invalid prepared manifest")
109
+ for e in m.get("extras", []):
110
+ safe_path(e["path"])
111
+ if not HASH.fullmatch(e["sha256"]) or not 0 <= e["mode"] <= 0o777:
112
+ raise ValueError("invalid prepared file")
113
+ if m.get("extras") and not HASH.fullmatch(m.get("extras_key") or ""):
114
+ raise ValueError("invalid prepared files archive")
115
+ for f in m.get("files", []):
116
+ safe_path(f["path"])
117
+ if not OID.fullmatch(f["oid"]) or not 0 <= f["mode"] <= 0o777 or "\n" in f["path"] or "\r" in f["path"]:
118
+ raise ValueError("invalid prepared media file")
119
+ for p in m.get("remove", []):
120
+ safe_path(p)
121
+ if not all(OID.fullmatch(o) for o in m.get("shallow", [])):
122
+ raise ValueError("invalid shallow boundary")
123
+ return m
124
+
125
+
126
+ def add_shallow(gitdir, oids):
127
+ """A shallow clone's boundary, merged into a repository's own (its oldest commits have no parents here either)."""
128
+ if not oids:
129
+ return
130
+ path = os.path.join(gitdir, "shallow")
131
+ try:
132
+ have = set(open(path).read().split())
133
+ except FileNotFoundError:
134
+ have = set()
135
+ if not set(oids) <= have:
136
+ atomic(path, ("\n".join(sorted(have | set(oids))) + "\n").encode(), 0o644)
137
+
138
+
139
+ def canonical(m):
140
+ """The manifest's identity bytes, the same as the CLI's canonical JSON (sorted keys, no spaces, UTF-8)."""
141
+ return json.dumps(m, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
142
+
143
+
144
+ def revision_of(m):
145
+ return hashlib.sha256(canonical(m)).hexdigest()
146
+
147
+
148
+ class Parts:
149
+ """Where uploaded 8 MiB parts are: the cache's own directory, or a conversation VM's local disk."""
150
+
151
+ def __init__(self, directory):
152
+ self.dir = directory
153
+
154
+ def path(self, key):
155
+ if not HASH.fullmatch(key):
156
+ raise ValueError("invalid content block")
157
+ return os.path.join(self.dir, key)
158
+
159
+ def listing(self):
160
+ try:
161
+ return sorted(n for n in os.listdir(self.dir) if HASH.fullmatch(n))
162
+ except FileNotFoundError:
163
+ return []
164
+
165
+ def require(self, keys):
166
+ missing = [k for k in dict.fromkeys(keys) if not os.path.isfile(self.path(k))]
167
+ if missing:
168
+ raise Refused("missing", "%d upload parts are missing" % len(missing), chunks=missing)
169
+
170
+ def verify(self, keys):
171
+ """The corrupt or missing parts, removed so the sender uploads them again."""
172
+ bad = []
173
+ for key in dict.fromkeys(keys):
174
+ p = self.path(key)
175
+ try:
176
+ with open(p, "rb") as f:
177
+ ok = hashlib.file_digest(f, "sha256").hexdigest() == key
178
+ except FileNotFoundError:
179
+ ok = False
180
+ if not ok:
181
+ bad.append(key)
182
+ try:
183
+ os.unlink(p)
184
+ except FileNotFoundError:
185
+ pass
186
+ return bad
187
+
188
+ def remove(self, keys):
189
+ for key in dict.fromkeys(keys):
190
+ try:
191
+ os.unlink(self.path(key))
192
+ except FileNotFoundError:
193
+ pass
194
+
195
+
196
+ class Cache:
197
+ def __init__(self, root):
198
+ if os.path.islink(root):
199
+ raise ValueError("project cache is a symlink")
200
+ self.root = root
201
+ self.repo = os.path.join(root, "history.git")
202
+
203
+ def path(self, *p):
204
+ return os.path.join(self.root, *p)
205
+
206
+ def parts(self, directory=None):
207
+ if directory and directory != self.path("chunks"):
208
+ if not PARTS.fullmatch(directory) or ".." in directory.split("/"):
209
+ raise ValueError("invalid parts directory")
210
+ return Parts(directory)
211
+ return Parts(self.path("chunks"))
212
+
213
+ def lock(self, shared=False):
214
+ # On the project space (NFSv4, local_lock=none) flock is a POSIX lock: a shared one needs a readable file.
215
+ os.makedirs(self.root, mode=0o700, exist_ok=True)
216
+ f = open(self.path("lock"), "a+")
217
+ fcntl.flock(f, fcntl.LOCK_SH if shared else fcntl.LOCK_EX)
218
+ return f
219
+
220
+ def ensure_repo(self, fmt):
221
+ if not os.path.isfile(os.path.join(self.repo, "HEAD")):
222
+ os.makedirs(self.root, mode=0o700, exist_ok=True)
223
+ tmp = self.path("history.git.tmp-" + uuid.uuid4().hex)
224
+ git("init", "-q", "--bare", "--object-format=" + fmt, tmp)
225
+ git("-C", tmp, "config", "core.logAllRefUpdates", "false")
226
+ try:
227
+ os.rename(tmp, self.repo)
228
+ except OSError as error:
229
+ if error.errno not in (errno.EEXIST, errno.ENOTEMPTY):
230
+ raise
231
+ shutil.rmtree(tmp, ignore_errors=True)
232
+ got = out("--git-dir=" + self.repo, "rev-parse", "--show-object-format")
233
+ if got != fmt:
234
+ raise Refused("format", "the project cache stores %s objects, this repository uses %s" % (got, fmt))
235
+
236
+ def git(self, *argv, **kw):
237
+ return git("--git-dir=" + self.repo, *argv, **kw)
238
+
239
+ def manifest(self, revision):
240
+ if not HASH.fullmatch(revision):
241
+ raise ValueError("invalid prepared revision")
242
+ m = valid_manifest(json.loads(open(self.path("manifests", revision + ".json"), "rb").read()))
243
+ if revision_of(m) != revision:
244
+ raise ValueError("prepared manifest identity changed")
245
+ return m
246
+
247
+ # The markers the probe reads instead of Git (written by import, under the cache lock).
248
+ def state(self):
249
+ return read_json(self.path("state.json"), {}) or {}
250
+
251
+ def published(self, revision):
252
+ return os.path.isfile(self.path("published", revision + ".json"))
253
+
254
+ def ready(self, revision):
255
+ return os.path.isfile(self.path("ready", revision + ".json"))
256
+
257
+ def media_index(self):
258
+ try:
259
+ return set(open(self.path("media-index")).read().split())
260
+ except FileNotFoundError:
261
+ return set()
262
+
263
+ def present_objects(self, oids):
264
+ """Git's own answer (after the reply only: Git on the cache is slow)."""
265
+ if not oids or not os.path.isfile(os.path.join(self.repo, "HEAD")):
266
+ return set()
267
+ listing = self.git("cat-file", "--batch-check=%(objectname) %(objecttype)", data=("\n".join(oids) + "\n").encode()).stdout.decode().split("\n")
268
+ return {line.split(" ")[0] for line in listing if line.endswith(" blob")}
269
+
270
+
271
+ def imported(cache, revision):
272
+ return read_json(cache.path("imports", revision + ".json"), []) or []
273
+
274
+
275
+ def mark_used(cache, revision):
276
+ """When a revision was last wanted, in the file itself: the project space's file times are not reliable."""
277
+ atomic(cache.path("used", revision + ".json"), json.dumps({"at": int(time.time())}).encode())
278
+
279
+
280
+ def refresh_state(cache):
281
+ """state.json: the commits the cache holds whole (what a sender may leave out of a pack) and whether its history is
282
+ blobless. Written under the cache lock after the cache changes."""
283
+ tips = cache.git("for-each-ref", "--sort=-committerdate", "--count=64", "--format=%(objectname)",
284
+ "refs/shard/prepared/", "refs/shard/origin/", "refs/shard/upstream/").stdout.decode().split()
285
+ partial = cache.git("config", "--bool", "remote.origin.promisor", check=False).stdout.strip() == b"true"
286
+ atomic(cache.path("state.json"), json.dumps({"tips": list(dict.fromkeys(tips)), "partial": partial,
287
+ "format": out("--git-dir=" + cache.repo, "rev-parse", "--show-object-format")}).encode())
288
+
289
+
290
+ def prefetch_dir(root):
291
+ """Where the VM fetches a new conversation's branch from origin at boot (guest coding 53+, prefetch.go), keyed by
292
+ its worktree as the copy seed's FETCH_ORIGIN reads it: history/ and tip/ bare repositories, remote, commit."""
293
+ home = os.environ.get("HOME") or "/home/user"
294
+ return os.path.join(home, ".cache", "shardflux", "prefetch", hashlib.sha256(root.encode("utf-8")).hexdigest()[:16])
295
+
296
+
297
+ def prefetch_status(root, url):
298
+ """"ready" or "running" when the VM holds, or is still fetching, this worktree's branch from `url`. With neither,
299
+ a marker keeps a late one from starting: this start brings its history another way."""
300
+ if not url:
301
+ return None
302
+ pre = prefetch_dir(root)
303
+ try:
304
+ os.makedirs(os.path.dirname(pre), exist_ok=True)
305
+ fd = os.open(pre + ".lock", os.O_RDWR | os.O_CREAT | os.O_CLOEXEC, 0o600)
306
+ except OSError:
307
+ return None
308
+ try:
309
+ try:
310
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
311
+ except BlockingIOError:
312
+ return "running"
313
+ if os.path.isdir(os.path.join(pre, "history")):
314
+ with open(os.path.join(pre, "remote"), encoding="utf-8", errors="replace") as f:
315
+ return "ready" if f.read().strip() == url else None
316
+ os.makedirs(pre, exist_ok=True)
317
+ return None
318
+ except OSError:
319
+ return None
320
+ finally:
321
+ os.close(fd)
322
+
323
+
324
+ def wait_prefetch(root):
325
+ """The lock of the VM's own fetch of this worktree's branch, once that fetch is over (bounded wait), or None when
326
+ there is none. Waited for outside the cache lock: other VMs' imports go on meanwhile."""
327
+ pre = prefetch_dir(root)
328
+ if not (os.path.exists(pre + ".lock") or os.path.isdir(pre)):
329
+ return None
330
+ fd = os.open(pre + ".lock", os.O_RDWR | os.O_CREAT | os.O_CLOEXEC, 0o600)
331
+ deadline = time.monotonic() + PREFETCH_WAIT
332
+ while True:
333
+ try:
334
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
335
+ return fd
336
+ except BlockingIOError:
337
+ if time.monotonic() > deadline:
338
+ os.close(fd)
339
+ return None
340
+ time.sleep(0.05)
341
+
342
+
343
+ def adopt_prefetch(cache, root, url, fmt):
344
+ """Moves the VM's own fetch of this branch (its lock held: wait_prefetch) into the cache: the history a fetch from
345
+ origin would bring, already here. Returns its commit, or None (another remote, failed). Leaves a marker so no second
346
+ prefetch starts. Called under the cache lock."""
347
+ pre = prefetch_dir(root)
348
+ try:
349
+ with open(os.path.join(pre, "remote"), encoding="utf-8", errors="replace") as f:
350
+ remote = f.read().strip()
351
+ with open(os.path.join(pre, "commit"), encoding="utf-8", errors="replace") as f:
352
+ commit = f.read().strip()
353
+ except OSError:
354
+ return None
355
+ if fmt != "sha1" or remote != url or not re.fullmatch(r"[a-f0-9]{40}", commit):
356
+ return None
357
+ dest = os.path.join(cache.repo, "objects", "pack")
358
+ packs = [os.path.join(pre, repo, "objects", "pack", n) for repo in ("history", "tip")
359
+ if os.path.isdir(os.path.join(pre, repo, "objects", "pack")) for n in os.listdir(os.path.join(pre, repo, "objects", "pack"))
360
+ if n.startswith("pack-")]
361
+ # Each pack before its index: a reader never sees an index without its pack. Another file system: copy, then
362
+ # rename into place.
363
+ for suffix in (".pack", ".rev", ".promisor", ".idx"):
364
+ for src in packs:
365
+ if src.endswith(suffix):
366
+ tmp = os.path.join(dest, ".adopt-" + uuid.uuid4().hex)
367
+ shutil.copyfile(src, tmp)
368
+ os.replace(tmp, os.path.join(dest, os.path.basename(src)))
369
+ shutil.rmtree(pre, ignore_errors=True)
370
+ os.makedirs(pre, exist_ok=True)
371
+ return commit if cache.git("cat-file", "-e", commit + "^{commit}", check=False).returncode == 0 else None
372
+
373
+
374
+ def probe(cache, req):
375
+ """What the cache holds for this revision, from its markers alone. With `apply`, a revision whose snapshot and
376
+ archive are here is checked out in the same exec (a warm start is one exec); media on their way land later."""
377
+ m = valid_manifest(req["manifest"])
378
+ revision = revision_of(m)
379
+ ready = cache.ready(revision)
380
+ published = ready or cache.published(revision)
381
+ extras = not m.get("extras") or os.path.isfile(cache.path("extras", m["extras_key"]))
382
+ media = {f["oid"] for f in m.get("files", [])}
383
+ missing = [] if ready else sorted(media - cache.media_index())
384
+ state = cache.state()
385
+ mark("probe")
386
+ applied = None
387
+ if published and extras and req.get("apply"):
388
+ applied = apply(cache, dict(req["apply"], revision=revision), present=media - set(missing))
389
+ if req.get("pin") and (not applied or missing):
390
+ AFTER.append(lambda: pin(cache, {"id": req["pin"], "revision": revision}))
391
+ if published:
392
+ AFTER.append(lambda: mark_used(cache, revision))
393
+ AFTER.append(lambda: maybe_prune(cache))
394
+ parts = cache.parts(req.get("parts_dir"))
395
+ # A start whose history the cache may lack: the VM's own fetch of its branch can stand in for sending it.
396
+ fetched = prefetch_status(req["apply"]["root"], m.get("origin")) if req.get("apply") and not published else None
397
+ return {"revision": revision, "ready": published and extras and not missing, "published": published, "tips": state.get("tips", []),
398
+ "partial": state.get("partial", False), "chunks": parts.listing(), "imported": imported(cache, revision), "extras_present": extras,
399
+ "missing_files": missing, **({"applied": applied} if applied else {}), **({"prefetch": fetched} if fetched else {})}
400
+
401
+
402
+ def origin(cache, req):
403
+ """Fetch the branch's history from its origin into the cache, inside the cloud: commits and trees for all history,
404
+ plus every file of the branch tip in one pack. Older blobs stay with origin (a partial cache)."""
405
+ url, ref, name, expect = req["url"], req["ref"], req["name"], req.get("expect")
406
+ if not re.fullmatch(r"refs/heads/[^\s\0~^:?*\[\\]+", ref) or not re.fullmatch(r"[a-f0-9]{16}", name) or url.startswith("-") \
407
+ or (expect is not None and not OID.match(expect)) or (req.get("prefetch") is not None and not os.path.isabs(req["prefetch"])):
408
+ raise ValueError("invalid origin request")
409
+ has = lambda oid: oid is not None and cache.git("cat-file", "-e", oid + "^{commit}", check=False).returncode == 0
410
+ prefetched = wait_prefetch(req["prefetch"]) if req.get("prefetch") else None
411
+ try:
412
+ return fetch_origin(cache, req, url, ref, name, expect, has, prefetched is not None)
413
+ finally:
414
+ if prefetched is not None:
415
+ os.close(prefetched)
416
+
417
+
418
+ def fetch_origin(cache, req, url, ref, name, expect, has, prefetched):
419
+ with cache.lock():
420
+ cache.ensure_repo(req["format"])
421
+ cache.git("config", "remote.origin.url", url)
422
+ dst = "refs/shard/origin/" + name
423
+ # The VM fetched this conversation's branch at boot: its history is the cache's, with no second fetch when it
424
+ # holds the commit this computer's upstream is at (or a newer one).
425
+ adopted = adopt_prefetch(cache, req["prefetch"], url, req["format"]) if prefetched else None
426
+ if adopted:
427
+ cache.git("config", "remote.origin.promisor", "true")
428
+ cache.git("config", "remote.origin.partialclonefilter", "blob:none")
429
+ cache.git("update-ref", dst, adopted)
430
+ had = cache.git("rev-parse", "-q", "--verify", dst, check=False).returncode == 0
431
+ partial = cache.git("config", "--bool", "remote.origin.promisor", check=False).stdout.strip() == b"true"
432
+ args = ["-c", "credential.interactive=false", "fetch", "-q", "--no-tags", "--no-write-fetch-head"]
433
+ if adopted and (expect is None or has(expect)):
434
+ pass
435
+ elif had and not partial:
436
+ cache.git(*args, "origin", "+%s:%s" % (ref, dst))
437
+ else:
438
+ # The tip's files in one depth-1 pack beside the blobless history: a checkout of the tip asks origin for
439
+ # nothing one blob at a time.
440
+ tip_repo = cache.path("tip-" + uuid.uuid4().hex)
441
+ git("init", "-q", "--bare", "--object-format=" + req["format"], tip_repo)
442
+ try:
443
+ tip = subprocess.Popen(["git", "-C", tip_repo, "-c", "credential.interactive=false", "-c", "fetch.unpackLimit=1",
444
+ "fetch", "-q", "--no-tags", "--depth=1", url, "+%s:refs/tip" % ref],
445
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=dict(os.environ, GIT_TERMINAL_PROMPT="0"))
446
+ cache.git("config", "remote.origin.promisor", "true")
447
+ cache.git("config", "remote.origin.partialclonefilter", "blob:none")
448
+ fetched = cache.git(*args, "--filter=blob:none", "origin", "+%s:%s" % (ref, dst), check=False)
449
+ if tip.wait() == 0:
450
+ take_packs(tip_repo, cache.repo)
451
+ if fetched.returncode != 0:
452
+ if not had:
453
+ cache.git("config", "--unset", "remote.origin.promisor", check=False)
454
+ cache.git("config", "--unset", "remote.origin.partialclonefilter", check=False)
455
+ raise Refused("origin", fetched.stderr.decode("utf-8", "replace").strip()[-600:])
456
+ finally:
457
+ shutil.rmtree(tip_repo, ignore_errors=True)
458
+ tip = out("--git-dir=" + cache.repo, "rev-parse", dst)
459
+ whole = has(expect)
460
+ if whole and expect != tip and cache.git("config", "--bool", "remote.origin.promisor", check=False).stdout.strip() == b"true":
461
+ # This computer's upstream is behind origin's tip: the cache holds that commit's history but only the tip's
462
+ # files. The sender leaves out what its upstream reaches, so that commit's files come too, in one pack.
463
+ whole = fetch_files(cache, url, expect, req["format"])
464
+ cache.git("pack-refs", "--all")
465
+ refresh_state(cache)
466
+ state = cache.state()
467
+ return {"tip": tip, "tips": state["tips"], "partial": state["partial"], "adopted": bool(adopted),
468
+ **({"has_expect": whole} if expect else {})}
469
+
470
+
471
+ def take_packs(source, repo):
472
+ """Moves a fetch's packs into the cache, each pack before its index: a reader never sees an index without its pack."""
473
+ pack_dir = os.path.join(source, "objects", "pack")
474
+ packs = sorted(n for n in os.listdir(pack_dir) if n.startswith("pack-"))
475
+ for suffix in (".pack", ".rev", ".promisor", ".idx"):
476
+ for n in packs:
477
+ if n.endswith(suffix):
478
+ os.replace(os.path.join(pack_dir, n), os.path.join(repo, "objects", "pack", n))
479
+
480
+
481
+ def fetch_files(cache, url, commit, fmt):
482
+ """Every file of one commit, from origin in one depth-1 pack. False when origin will not send it."""
483
+ tip_repo = cache.path("tip-" + uuid.uuid4().hex)
484
+ git("init", "-q", "--bare", "--object-format=" + fmt, tip_repo)
485
+ try:
486
+ fetched = git("-C", tip_repo, "-c", "credential.interactive=false", "-c", "fetch.unpackLimit=1", "fetch", "-q", "--no-tags",
487
+ "--depth=1", url, "+%s:refs/tip" % commit, check=False)
488
+ if fetched.returncode != 0:
489
+ return False
490
+ take_packs(tip_repo, cache.repo)
491
+ return True
492
+ finally:
493
+ shutil.rmtree(tip_repo, ignore_errors=True)
494
+
495
+
496
+ def index_pack(gitdir, parts, keys, thin):
497
+ """Streams the parts of one pack into `index-pack` for this repository (its alternates resolve a thin pack's bases)."""
498
+ parts.require(keys)
499
+ args = ["--git-dir=" + gitdir, "-c", "core.hooksPath=/dev/null", "index-pack", "--stdin"] + (["--fix-thin"] if thin else [])
500
+ p = subprocess.Popen(["git", *args], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
501
+ try:
502
+ try:
503
+ for key in keys:
504
+ with open(parts.path(key), "rb") as f:
505
+ shutil.copyfileobj(f, p.stdin, 1 << 20)
506
+ except BrokenPipeError:
507
+ pass
508
+ p.stdin.close()
509
+ err = p.stderr.read()
510
+ if p.wait() != 0:
511
+ bad = parts.verify(keys)
512
+ if bad:
513
+ raise Refused("corrupt", "%d upload parts failed verification" % len(bad), chunks=bad)
514
+ raise RuntimeError("Git rejected the pack: " + err.decode("utf-8", "replace").strip()[-800:])
515
+ finally:
516
+ if p.poll() is None:
517
+ p.kill()
518
+ p.wait()
519
+
520
+
521
+ def assemble(parts, keys, target, digest):
522
+ """Concatenates parts into one file whose SHA-256 must be `digest`."""
523
+ if os.path.isfile(target):
524
+ return
525
+ parts.require(keys)
526
+ os.makedirs(os.path.dirname(target), mode=0o700, exist_ok=True)
527
+ tmp = "%s.tmp-%s" % (target, uuid.uuid4().hex)
528
+ h = hashlib.sha256()
529
+ with open(tmp, "xb") as f:
530
+ os.fchmod(f.fileno(), 0o600)
531
+ for key in keys:
532
+ with open(parts.path(key), "rb") as part:
533
+ while b := part.read(1 << 20):
534
+ h.update(b)
535
+ f.write(b)
536
+ f.flush()
537
+ os.fsync(f.fileno())
538
+ if h.hexdigest() != digest:
539
+ os.unlink(tmp)
540
+ raise Refused("corrupt", "the project files archive failed verification", chunks=parts.verify(keys))
541
+ os.replace(tmp, target)
542
+ now = time.time()
543
+ os.utime(target, (now, now))
544
+
545
+
546
+ def keep_files(cache, m, revision):
547
+ """A tree naming the revision's media blobs that have arrived keeps them in the cache while the revision is; the
548
+ media index lets the probe answer without Git."""
549
+ have = sorted(cache.present_objects([f["oid"] for f in m.get("files", [])]))
550
+ if have:
551
+ tree = out("--git-dir=" + cache.repo, "mktree", "-z", data="".join("100644 blob %s\t%s\0" % (o, o) for o in have).encode())
552
+ cache.git("update-ref", "refs/shard/content/" + revision, tree)
553
+ index = cache.media_index()
554
+ if not set(have) <= index:
555
+ atomic(cache.path("media-index"), ("\n".join(sorted(index | set(have))) + "\n").encode())
556
+ return set(have)
557
+
558
+
559
+ def import_(cache, req):
560
+ """Index the uploaded packs and the files archive into the cache; publish S once every object it needs is here. A
561
+ failure refreshes the cache's record of what it holds, so the sender's next attempt negotiates from the truth."""
562
+ with cache.lock():
563
+ try:
564
+ return import_locked(cache, req)
565
+ except Exception as error:
566
+ if not (isinstance(error, Refused) and error.reason in ("missing", "corrupt")):
567
+ try:
568
+ cache.ensure_repo(req["manifest"]["format"])
569
+ refresh_state(cache)
570
+ except Exception:
571
+ pass
572
+ raise
573
+
574
+
575
+ def import_locked(cache, req):
576
+ m = valid_manifest(req["manifest"])
577
+ revision = revision_of(m)
578
+ parts = cache.parts(req.get("parts_dir"))
579
+ cache.ensure_repo(m["format"])
580
+ consumed, done = [], imported(cache, revision)
581
+ for pack in req.get("packs", []):
582
+ if not HASH.fullmatch(pack["key"]):
583
+ raise ValueError("invalid pack identity")
584
+ if pack["key"] not in done:
585
+ index_pack(cache.repo, parts, pack["chunks"], pack.get("thin", False))
586
+ done.append(pack["key"])
587
+ atomic(cache.path("imports", revision + ".json"), json.dumps(done).encode())
588
+ consumed += pack["chunks"]
589
+ mark("index_pack")
590
+ if m.get("extras") and req.get("extras_chunks") is not None:
591
+ assemble(parts, req["extras_chunks"], cache.path("extras", m["extras_key"]), m["extras_key"])
592
+ consumed += req["extras_chunks"]
593
+ atomic(cache.path("manifests", revision + ".json"), canonical(m))
594
+ add_shallow(cache.repo, m.get("shallow", []))
595
+ if not cache.published(revision):
596
+ tops = [m["seed"], m["head"]] + ([m["upstream"]["oid"]] if m.get("upstream") else [])
597
+ partial = cache.git("config", "--bool", "remote.origin.promisor", check=False).stdout.strip() == b"true"
598
+ # Only the new objects are walked (what the cache's refs reach is whole already); a blobless cache accepts
599
+ # the blobs its origin promises.
600
+ check = cache.git("rev-list", "--objects", "--quiet", *(["--missing=allow-promisor"] if partial else []),
601
+ *tops, "--not", "--all", check=False)
602
+ if check.returncode != 0:
603
+ raise Refused("incomplete", "the snapshot is missing objects: " + check.stderr.decode("utf-8", "replace").strip()[-400:])
604
+ updates = "update refs/shard/prepared/%s %s\n" % (revision, m["seed"])
605
+ if m.get("upstream"):
606
+ updates += "update refs/shard/upstream/%s %s\n" % (revision, m["upstream"]["oid"])
607
+ cache.git("update-ref", "--stdin", data=updates.encode())
608
+ cache.git("pack-refs", "--all")
609
+ atomic(cache.path("published", revision + ".json"), json.dumps({"at": int(time.time())}).encode())
610
+ refresh_state(cache)
611
+ mark_used(cache, revision)
612
+ have = keep_files(cache, m, revision)
613
+ missing_files = sorted({f["oid"] for f in m.get("files", [])} - have)
614
+ extras = not m.get("extras") or os.path.isfile(cache.path("extras", m["extras_key"]))
615
+ if extras and not missing_files:
616
+ atomic(cache.path("ready", revision + ".json"), json.dumps({"at": int(time.time())}).encode())
617
+ mark("publish")
618
+ parts.remove(consumed)
619
+ return {"revision": revision, "complete": extras and not missing_files, "missing_files": missing_files, "tips": cache.state().get("tips", [])}
620
+
621
+
622
+ def stage(cache, req):
623
+ """A conversation start's upload, indexed into the conversation's own repository on the VM's disk and checked out
624
+ at once (the cache answers no Git question on the way). The cache receives the same packs after the reply."""
625
+ m = valid_manifest(req["manifest"])
626
+ revision = revision_of(m)
627
+ parts = cache.parts(req["parts_dir"])
628
+ target = dict(req["apply"], revision=revision)
629
+ root = target["root"]
630
+ if not root.startswith("/") or ".." in root.split("/"):
631
+ raise ValueError("invalid prepared binding")
632
+ current = marker(root)
633
+ applied = None
634
+ if current and current.get("revision") == revision and current.get("state") == "ready":
635
+ applied = {"revision": revision, "mode": "existing"}
636
+ inplace = os.path.isdir(os.path.join(root, ".git")) or (os.path.isdir(root) and bool(os.listdir(root)))
637
+ lock = cache.lock(shared=True)
638
+ work = None
639
+ try:
640
+ if not applied:
641
+ work = prepare_root(cache, m, root, target, inplace)
642
+ gitdir = os.path.join(work or root, ".git")
643
+ for pack in req.get("packs", []):
644
+ if not HASH.fullmatch(pack["key"]):
645
+ raise ValueError("invalid pack identity")
646
+ index_pack(gitdir, parts, pack["chunks"], pack.get("thin", False))
647
+ mark("index_local")
648
+ extras_tar = None
649
+ if m.get("extras"):
650
+ cached = cache.path("extras", m["extras_key"])
651
+ extras_tar = cached if os.path.isfile(cached) else os.path.join(gitdir, "shard-extras-%s.tar" % m["extras_key"])
652
+ if extras_tar != cached:
653
+ assemble(parts, req.get("extras_chunks") or [], extras_tar, m["extras_key"])
654
+ media = {f["oid"] for f in m.get("files", [])}
655
+ # What this start sent, from the request itself: asking Git would read the media pack over the network.
656
+ local = set(req.get("media_staged", [])) & media
657
+ if not applied:
658
+ applied = apply_locked(cache, target, revision, root, target["branch"], m=m, extras_tar=extras_tar,
659
+ present=local | (media & cache.media_index()), inplace=inplace, work=work)
660
+ else:
661
+ # Media that followed the prompt: they land before this call returns.
662
+ land(cache, root, [])
663
+ except BaseException as error:
664
+ discard(work, root)
665
+ # Parts the sender can resend (missing, damaged) stay for its retry; after any other failure it negotiates again.
666
+ if not (isinstance(error, Refused) and error.reason in ("missing", "corrupt")):
667
+ shutil.rmtree(parts.dir, ignore_errors=True)
668
+ raise
669
+ finally:
670
+ lock.close()
671
+ packs, extras_chunks = req.get("packs", []), req.get("extras_chunks")
672
+ if packs or extras_chunks:
673
+ # The cache gets what this start sent, for the next start, after the reply.
674
+ AFTER.insert(0, lambda: import_(cache, {"manifest": m, "packs": packs, "extras_chunks": extras_chunks, "parts_dir": parts.dir}))
675
+ AFTER.append(lambda: maybe_prune(cache))
676
+ if req.get("pin") and not (media - local - cache.media_index()):
677
+ AFTER.append(lambda: pin(cache, {"id": req["pin"], "release": True}))
678
+ return {"revision": revision, "applied": applied, "timings": TIMINGS}
679
+
680
+
681
+ def present_in(root, oids):
682
+ """Which of these blobs this repository (with its alternates) holds."""
683
+ if not oids:
684
+ return set()
685
+ listing = git("-C", root, "cat-file", "--batch-check=%(objectname) %(objecttype)", data=("\n".join(oids) + "\n").encode()).stdout.decode().split("\n")
686
+ return {line.split(" ")[0] for line in listing if line.endswith(" blob")}
687
+
688
+
689
+ def parent_fd(rootfd, path):
690
+ """The parent directory of path, opened without following symbolic links (created when missing)."""
691
+ parts = path.split("/")
692
+ fd = os.dup(rootfd)
693
+ try:
694
+ for part in parts[:-1]:
695
+ try:
696
+ os.mkdir(part, 0o755, dir_fd=fd)
697
+ except FileExistsError:
698
+ pass
699
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
700
+ os.close(fd)
701
+ fd = child
702
+ return fd, parts[-1]
703
+ except BaseException:
704
+ os.close(fd)
705
+ raise
706
+
707
+
708
+ def place(root, path, mode, write, symlink=None, only_if_absent=False):
709
+ """Writes one file (or link) through its real parent directories, replacing atomically; False when skipped."""
710
+ rootfd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
711
+ try:
712
+ fd, name = parent_fd(rootfd, safe_path(path))
713
+ finally:
714
+ os.close(rootfd)
715
+ tmp = ".shard-tmp-" + uuid.uuid4().hex
716
+ try:
717
+ if only_if_absent:
718
+ try:
719
+ os.lstat(name, dir_fd=fd)
720
+ return False
721
+ except FileNotFoundError:
722
+ pass
723
+ if symlink is not None:
724
+ os.symlink(symlink, tmp, dir_fd=fd)
725
+ else:
726
+ dest = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode, dir_fd=fd)
727
+ with os.fdopen(dest, "wb") as w:
728
+ write(w)
729
+ os.fchmod(w.fileno(), mode)
730
+ try:
731
+ if only_if_absent:
732
+ os.link(tmp, name, src_dir_fd=fd, dst_dir_fd=fd, follow_symlinks=False)
733
+ else:
734
+ os.replace(tmp, name, src_dir_fd=fd, dst_dir_fd=fd)
735
+ except FileExistsError:
736
+ return False # the agent wrote it meanwhile: its version stands
737
+ except IsADirectoryError:
738
+ # A directory where the user's computer has a file (a template's leftover): the file wins.
739
+ shutil.rmtree(os.path.join(root, path))
740
+ os.replace(tmp, name, src_dir_fd=fd, dst_dir_fd=fd)
741
+ return True
742
+ finally:
743
+ try:
744
+ os.unlink(tmp, dir_fd=fd)
745
+ except FileNotFoundError:
746
+ pass
747
+ os.close(fd)
748
+
749
+
750
+ def extract_extras(m, root, archive):
751
+ """The .env files and filter output, written exactly as they are on the user's computer."""
752
+ if not m.get("extras"):
753
+ return 0
754
+ import tarfile
755
+ wanted = {e["path"]: e for e in m["extras"]}
756
+ with open(archive, "rb") as f:
757
+ if hashlib.file_digest(f, "sha256").hexdigest() != m["extras_key"]:
758
+ raise RuntimeError("the project files archive changed")
759
+ written = 0
760
+ with tarfile.open(archive, "r:") as tar:
761
+ for info in tar:
762
+ e = wanted.get(info.name)
763
+ if e is None:
764
+ continue
765
+ if e["symlink"]:
766
+ if not info.issym() or info.linkname != e["link_target"]:
767
+ raise RuntimeError("archive entry differs: " + info.name)
768
+ place(root, info.name, 0o777, None, symlink=e["link_target"])
769
+ else:
770
+ src, h = tar.extractfile(info), hashlib.sha256()
771
+
772
+ def copy(w, src=src, h=h):
773
+ while b := src.read(1 << 20):
774
+ h.update(b)
775
+ w.write(b)
776
+ place(root, info.name, e["mode"], copy)
777
+ if h.hexdigest() != e["sha256"]:
778
+ raise RuntimeError("archive entry differs: " + info.name)
779
+ written += 1
780
+ if written != len(wanted):
781
+ raise RuntimeError("the project files archive is incomplete")
782
+ return written
783
+
784
+
785
+ def write_blobs(root, files, only_if_absent):
786
+ """Streams media blobs from the worktree's repository (its alternates include the cache) into their paths."""
787
+ if not files:
788
+ return []
789
+ p = subprocess.Popen(["git", "-C", root, "cat-file", "--batch"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
790
+ placed = []
791
+ try:
792
+ p.stdin.write(("".join(f["oid"] + "\n" for f in files)).encode())
793
+ p.stdin.close()
794
+ for f in files:
795
+ header = p.stdout.readline().decode().split()
796
+ if len(header) != 3 or header[0] != f["oid"] or header[1] != "blob":
797
+ raise RuntimeError("missing media blob " + f["oid"])
798
+ size = int(header[2])
799
+
800
+ def copy(w, size=size):
801
+ left = size
802
+ while left:
803
+ b = p.stdout.read(min(left, 1 << 20))
804
+ if not b:
805
+ raise RuntimeError("short media blob")
806
+ w.write(b)
807
+ left -= len(b)
808
+ if place(root, f["path"], f["mode"], copy, only_if_absent=only_if_absent):
809
+ placed.append(f["path"])
810
+ else:
811
+ for at in range(0, size, 1 << 20):
812
+ p.stdout.read(min(1 << 20, size - at))
813
+ p.stdout.read(1)
814
+ finally:
815
+ p.stdout.close()
816
+ p.wait()
817
+ return placed
818
+
819
+
820
+ def exclude_pattern(path):
821
+ """A gitignore line matching exactly this path from the worktree root."""
822
+ s = "".join("\\" + c if c in "\\*?[" else c for c in path)
823
+ if s[:1] in ("!", "#"):
824
+ s = "\\" + s
825
+ stripped = s.rstrip(" ")
826
+ return "/" + stripped + "\\ " * (len(s) - len(stripped))
827
+
828
+
829
+ def write_excludes(root, files):
830
+ """The media are untracked on the user's computer; here too: Git ignores them, so an agent never commits them."""
831
+ path = os.path.join(root, ".git", "info", "exclude")
832
+ try:
833
+ text = open(path, encoding="utf-8").read()
834
+ except FileNotFoundError:
835
+ text = ""
836
+ if EXCLUDE_BEGIN in text:
837
+ text = text[:text.index(EXCLUDE_BEGIN)] + text[text.index(EXCLUDE_END) + len(EXCLUDE_END):].lstrip("\n")
838
+ if files:
839
+ text = text.rstrip("\n") + ("\n" if text.strip() else "") + "\n".join([EXCLUDE_BEGIN] + [exclude_pattern(f["path"]) for f in files] + [EXCLUDE_END]) + "\n"
840
+ atomic(path, text.encode(), 0o644)
841
+
842
+
843
+ def configure(work, req, partial, root=None):
844
+ """The repository's settings in one write: origin, identity, the branch's upstream, exclude rules. A key already set
845
+ to the same value is left; one set to another is replaced (a template's checkout taken over keeps one origin)."""
846
+ root = root or work
847
+ cfg = [("gc.auto", "0")]
848
+ o = req.get("origin_url")
849
+ if o:
850
+ cfg.append(("remote.origin.url", o))
851
+ if partial:
852
+ # The cache is a blobless clone of origin: this repository asks origin for an old blob it needs.
853
+ cfg += [("remote.origin.promisor", "true"), ("remote.origin.partialclonefilter", "blob:none"),
854
+ ("core.repositoryformatversion", "1"), ("extensions.partialClone", "origin")]
855
+ for key in ("name", "email"):
856
+ if req.get("user", {}).get(key):
857
+ cfg.append(("user." + key, req["user"][key]))
858
+ up = req.get("tracking")
859
+ if up and o:
860
+ cfg += [("branch.%s.remote" % up["branch"], "origin"), ("branch.%s.merge" % up["branch"], up["merge"])]
861
+ if req.get("excludes") is not None:
862
+ atomic(os.path.join(work, ".git", "shard-sync-excludes"), req["excludes"].encode())
863
+ cfg.append(("core.excludesFile", os.path.join(root, ".git", "shard-sync-excludes")))
864
+ path = os.path.join(work, ".git", "config")
865
+ have = {}
866
+ for line in git("config", "--file", path, "--null", "--list", check=False).stdout.decode("utf-8", "replace").split("\0"):
867
+ key, _, value = line.partition("\n")
868
+ if key:
869
+ have.setdefault(key.lower(), []).append(value)
870
+ append = []
871
+ for key, value in cfg:
872
+ current = have.get(key.lower())
873
+ if current == [str(value)]:
874
+ continue
875
+ if current:
876
+ git("config", "--file", path, "--replace-all", key, str(value))
877
+ else:
878
+ append.append((key, value))
879
+ # One process for a new repository's keys: `git config --file` per key spawns Git each time.
880
+ with open(path, "a") as f:
881
+ sections = {}
882
+ for key, value in append:
883
+ section, _, name = key.rpartition(".")
884
+ sections.setdefault(section, []).append((name, value))
885
+ for section, items in sections.items():
886
+ head, _, sub = section.partition(".")
887
+ f.write('[%s%s]\n' % (head, ' "%s"' % sub.replace("\\", "\\\\").replace('"', '\\"') if sub else ""))
888
+ for name, value in items:
889
+ f.write('\t%s = "%s"\n' % (name, str(value).replace("\\", "\\\\").replace('"', '\\"')))
890
+
891
+
892
+ def marker(root):
893
+ return read_json(os.path.join(root, ".git", "shard-prepared.json"))
894
+
895
+
896
+ def pending_path(root):
897
+ return os.path.join(root, ".git", "shard-pending-files.json")
898
+
899
+
900
+ def apply(cache, req, present=None):
901
+ revision, root, branch = req["revision"], req["root"], req["branch"]
902
+ if not HASH.fullmatch(revision) or not BRANCH.fullmatch(branch) or not root.startswith("/") or ".." in root.split("/"):
903
+ raise ValueError("invalid prepared binding")
904
+ lock = cache.lock(shared=True)
905
+ try:
906
+ result = apply_locked(cache, req, revision, root, branch, present=present)
907
+ finally:
908
+ lock.close()
909
+ if req.get("release_pin"):
910
+ AFTER.append(lambda: pin(cache, {"id": req["release_pin"], "release": True}))
911
+ return result
912
+
913
+
914
+ def check_owner(root, revision, req):
915
+ """A conversation's worktree is never rewritten. Refused when this VM's conversation made it (our marker, from this
916
+ workspace) or another seed did (the conversation's branch is there: the copy seed, a restore). A project template's
917
+ checkout, and one a VM's image brought from the VM that saved it, are taken over."""
918
+ if req.get("workspace") is not None and not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", str(req["workspace"])):
919
+ raise ValueError("invalid workspace")
920
+ current = marker(root)
921
+ if current:
922
+ if current.get("revision") == revision or current.get("role") == "template" or req.get("replace_preparation"):
923
+ return
924
+ if current.get("workspace") and req.get("workspace") and current["workspace"] != req["workspace"]:
925
+ return
926
+ raise Refused("owned", "this worktree already holds another prepared revision")
927
+ if os.path.isdir(os.path.join(root, ".git")) and \
928
+ git("-C", root, "rev-parse", "-q", "--verify", "refs/heads/" + req["branch"], check=False).returncode == 0:
929
+ raise Refused("owned", "this worktree already holds the conversation")
930
+
931
+
932
+ def prepare_root(cache, m, root, req, inplace):
933
+ """The worktree's repository, borrowing the cache's objects; returns where it is built. A new one is built beside
934
+ the worktree path and moved into place whole (apply_locked): the guest starts the agent as soon as <worktree>/.git
935
+ exists, so it never sees one half made, and a failed start leaves nothing at the path. A checkout already there (a
936
+ project template's, files a new VM's image left) is taken over in place."""
937
+ check_owner(root, revision_of(m), req)
938
+ if os.path.lexists(root) and (os.path.islink(root) or not os.path.isdir(root)):
939
+ raise Refused("exists", "the worktree path is not a directory")
940
+ if inplace:
941
+ if not os.path.isdir(os.path.join(root, ".git")):
942
+ if not req.get("adopt"):
943
+ raise Refused("exists", "refusing to replace a nonempty directory")
944
+ git("init", "-q", "--object-format=" + m["format"], root)
945
+ work = root
946
+ else:
947
+ parent, name = os.path.split(root.rstrip("/"))
948
+ os.makedirs(parent, exist_ok=True)
949
+ work = os.path.join(parent, ".shard-preparing-%s-%s" % (name, uuid.uuid4().hex[:12]))
950
+ git("init", "-q", "--object-format=" + m["format"], work)
951
+ info = os.path.join(work, ".git", "objects", "info")
952
+ os.makedirs(info, exist_ok=True)
953
+ with open(os.path.join(info, "alternates"), "w") as f:
954
+ f.write(os.path.join(cache.repo, "objects") + "\n")
955
+ add_shallow(os.path.join(work, ".git"), m.get("shallow", []))
956
+ return work
957
+
958
+
959
+ def move_into_place(work, root):
960
+ """Moves a repository built aside into the worktree path (an empty directory there is replaced)."""
961
+ if work != root:
962
+ os.rename(work, root)
963
+
964
+
965
+ def discard(work, root):
966
+ if work and work != root:
967
+ shutil.rmtree(work, ignore_errors=True)
968
+
969
+
970
+ def write_setup_intent(intent):
971
+ """The hash a new conversation's worktree is set up with (project-template-setup.ts recordSetupIntent), written
972
+ before its repository appears: its guest then starts the agent with the setup's context and toolchains."""
973
+ conversation, deps_hash = intent.get("conversation", ""), intent.get("deps_hash", "")
974
+ if not re.fullmatch(r"conv_[a-z0-9]{26}", conversation) or not HASH.match(deps_hash):
975
+ raise ValueError("invalid setup intent")
976
+ home = os.environ.get("HOME") or "/home/user"
977
+ atomic(os.path.join(home, ".local", "share", "shardflux", "conversation-setup", conversation + ".json"),
978
+ ('{"deps_hash":"%s"}' % deps_hash).encode())
979
+
980
+
981
+ def apply_locked(cache, req, revision, root, branch, m=None, extras_tar=None, present=None, inplace=None, work=None):
982
+ """Check out S as the conversation branch. A fresh root borrows the cache's objects (alternates) and only writes
983
+ files; a root that already holds a checkout (the project template's, or an interrupted apply) moves to S in place,
984
+ keeping its ignored dependency folders. Media land after the reply; those not here yet stay pending."""
985
+ m = m or cache.manifest(revision)
986
+ current = marker(root)
987
+ if current and current.get("revision") == revision and current.get("state") == "ready":
988
+ return {"revision": revision, "mode": "existing", "pending": len(read_json(pending_path(root), []) or []), "landing": 0, "dependencies": 0,
989
+ "timings": TIMINGS}
990
+ if req.get("setup_intent"):
991
+ write_setup_intent(req["setup_intent"])
992
+ if inplace is None:
993
+ # A checkout already there (the template's, files a new VM's image left, an interrupted apply) moves to S in place.
994
+ inplace = os.path.isdir(os.path.join(root, ".git")) or (os.path.isdir(root) and bool(os.listdir(root)))
995
+ try:
996
+ work = work or prepare_root(cache, m, root, req, inplace)
997
+ result = checkout_locked(cache, req, revision, root, work, branch, m, extras_tar, present, inplace)
998
+ except BaseException:
999
+ discard(work, root)
1000
+ raise
1001
+ # Media are files Git ignores: the agent can start before they are written. They land right after the reply.
1002
+ land_now(cache, root, [], sync=not req.get("background", True))
1003
+ if req.get("after"):
1004
+ AFTER.append(lambda: run_after(root, req["after"]))
1005
+ if req.get("background", True):
1006
+ AFTER.append(lambda: background(root, req.get("dissociate", True) is True))
1007
+ return result
1008
+
1009
+
1010
+ def checkout_locked(cache, req, revision, root, work, branch, m, extras_tar, present, inplace):
1011
+ mode = "inplace" if inplace else "fresh"
1012
+ role = "template" if req.get("preparation") else "conversation"
1013
+ owner = {"revision": revision, "seed": m["seed"], "role": role, **({"workspace": req["workspace"]} if req.get("workspace") else {})}
1014
+ atomic(os.path.join(work, ".git", "shard-prepared.json"), json.dumps(dict(owner, state="applying")).encode())
1015
+ # Settings first: in a repository borrowing from a blobless cache, a file whose content only origin holds is fetched
1016
+ # by the checkout itself (in one batch), not missed.
1017
+ configure(work, req, cache.state().get("partial", False), root)
1018
+ mark("init")
1019
+ refs = "update refs/heads/%s %s\nupdate refs/worktree/shard/seed %s\nupdate refs/shard/seed %s\n" % (branch, m["seed"], m["seed"], m["seed"])
1020
+ if m.get("branch") and m["branch"] != "refs/heads/" + branch:
1021
+ refs += "update %s %s\n" % (m["branch"], m["head"])
1022
+ if m.get("upstream"):
1023
+ refs += "update %s %s\n" % (m["upstream"]["ref"], m["upstream"]["oid"])
1024
+ lfs = ["-c", "filter.lfs.smudge=cat", "-c", "filter.lfs.process=", "-c", "filter.lfs.required=false"]
1025
+ if mode == "inplace":
1026
+ # Changed files are rewritten, files S lacks removed, ignored dependency folders kept. The index's stat data
1027
+ # survives the image except inode and ctime, so only those are not compared.
1028
+ git("-C", work, "-c", "core.checkStat=minimal", "-c", "core.trustctime=false", *lfs, "checkout", "-q", "-f", "-B", branch, m["seed"])
1029
+ git("-C", work, "update-ref", "--stdin", data=refs.encode())
1030
+ else:
1031
+ git("-C", work, "update-ref", "--stdin", data=refs.encode())
1032
+ git("-C", work, "symbolic-ref", "HEAD", "refs/heads/" + branch)
1033
+ git("-C", work, *lfs, "read-tree", "--reset", "-u", m["seed"])
1034
+ mark("checkout")
1035
+ for path in m.get("remove", []):
1036
+ target = os.path.join(work, path)
1037
+ if os.path.lexists(target) and not os.path.isdir(target):
1038
+ os.unlink(target)
1039
+ extract_extras(m, work, extras_tar or cache.path("extras", m["extras_key"])) if m.get("extras") else None
1040
+ mark("extras")
1041
+ files = m.get("files", [])
1042
+ write_excludes(work, files)
1043
+ if mode == "inplace":
1044
+ # A template's older copy goes; the captured one lands (now or when it arrives), never over the agent's file.
1045
+ for f in files:
1046
+ target = os.path.join(work, f["path"])
1047
+ if os.path.lexists(target) and not os.path.isdir(target):
1048
+ os.unlink(target)
1049
+ if present is None:
1050
+ present = {f["oid"] for f in files} & cache.media_index()
1051
+ atomic(pending_path(work), json.dumps(files).encode())
1052
+ dependencies = copy_dependencies(work, req.get("dependency_source"), m) if mode == "fresh" else 0
1053
+ mark("dependencies")
1054
+ if req.get("dissociate") == "now":
1055
+ # A project template must not borrow from the cache: its own repository holds every object it names.
1056
+ land_now(cache, work, [], sync=True)
1057
+ git("-C", work, "repack", "-a", "-d", "-q")
1058
+ os.unlink(os.path.join(work, ".git", "objects", "info", "alternates"))
1059
+ mark("dissociate")
1060
+ atomic(os.path.join(work, ".git", "shard-prepared.json"), json.dumps(dict(owner, state="ready", deps_hash=m.get("deps_hash"), mode=mode)).encode())
1061
+ move_into_place(work, root)
1062
+ landing = {f["oid"] for f in files} & present
1063
+ return {"revision": revision, "mode": mode, "pending": len({f["oid"] for f in files} - present), "landing": len(landing),
1064
+ "dependencies": dependencies, "timings": TIMINGS}
1065
+
1066
+
1067
+ def run_after(root, after):
1068
+ """The caller's follow-up script (the seed's copy to the project hub), in the worktree, detached and quiet."""
1069
+ with open(os.devnull, "rb") as null_in, open(os.devnull, "wb") as null_out:
1070
+ subprocess.run(["sh", "-c", after["script"], "shard-seed", *after.get("args", [])], cwd=root, stdin=null_in, stdout=null_out, stderr=null_out)
1071
+
1072
+
1073
+ def land_now(cache, root, drop, sync):
1074
+ if sync:
1075
+ land(cache, root, drop)
1076
+ else:
1077
+ AFTER.append(lambda: land(cache, root, drop))
1078
+
1079
+
1080
+ def copy_dependencies(root, source, m):
1081
+ """A conversation outside the template's checkout (a Codex Cloud worktree) copies its ignored dependency folders
1082
+ when they were installed for the same dependency hash."""
1083
+ if not source or source == root or not m.get("deps_hash") or not os.path.isdir(os.path.join(source, ".git")):
1084
+ return 0
1085
+ recorded = (marker(source) or {}).get("deps_hash")
1086
+ if recorded and recorded != m["deps_hash"]:
1087
+ return 0
1088
+ copied = 0
1089
+ listed = git("-C", source, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z", check=False).stdout.decode("utf-8", "surrogateescape").split("\0")
1090
+ for rel in listed:
1091
+ rel = rel.rstrip("/")
1092
+ if not rel or os.path.basename(rel) not in DEPENDENCY_DIRS or ".git" in rel.split("/") or ".." in rel.split("/"):
1093
+ continue
1094
+ src, dst = os.path.join(source, rel), os.path.join(root, rel)
1095
+ if os.path.lexists(dst) or not os.path.isdir(src) or os.path.islink(src):
1096
+ continue
1097
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
1098
+ subprocess.run(["cp", "-a", src, dst], check=True)
1099
+ copied += 1
1100
+ return copied
1101
+
1102
+
1103
+ def background(root, dissociate):
1104
+ """At the lowest priority: refresh the index, and copy a borrowed object store into the worktree's own repository,
1105
+ so pruning the cache can never reach a running conversation."""
1106
+ script = 'cd "$1"; git -c core.hooksPath=/dev/null update-index -q --refresh >/dev/null 2>&1 || true\n'
1107
+ if dissociate:
1108
+ script += ('if [ -f .git/objects/info/alternates ]; then git -c core.hooksPath=/dev/null -c gc.auto=0 repack -a -d -q '
1109
+ '&& rm -f .git/objects/info/alternates; fi\n')
1110
+ with open(os.devnull, "rb") as null_in, open(os.devnull, "wb") as null_out:
1111
+ subprocess.Popen(["nice", "-n", "19", "sh", "-c", script, "shard-prepared", root], stdin=null_in, stdout=null_out, stderr=null_out,
1112
+ start_new_session=True)
1113
+
1114
+
1115
+ def land(cache, root, drop):
1116
+ """Writes the media its repository holds where nothing is yet, so the agent's own file always stands. Files that
1117
+ changed on the user's computer before they were sent never arrive as captured: `drop` gives them up."""
1118
+ pending = read_json(pending_path(root), []) or []
1119
+ pending = [f for f in pending if f["oid"] not in set(drop)]
1120
+ have = present_in(root, sorted({f["oid"] for f in pending}))
1121
+ arrived = [f for f in pending if f["oid"] in have]
1122
+ landed = write_blobs(root, arrived, True)
1123
+ rest = [f for f in pending if f["oid"] not in have]
1124
+ atomic(pending_path(root), json.dumps(rest).encode())
1125
+ return {"landed": len(landed), "kept": len(arrived) - len(landed), "pending": len(rest)}
1126
+
1127
+
1128
+ def materialize(cache, req):
1129
+ """Media that arrived after the start (in the cache, or staged into the worktree's repository)."""
1130
+ current = marker(req["root"])
1131
+ if not current or current.get("revision") != req["revision"]:
1132
+ raise Refused("owned", "this worktree holds another revision")
1133
+ return land(cache, req["root"], req.get("drop", []))
1134
+
1135
+
1136
+ def pin(cache, req):
1137
+ name = req["id"]
1138
+ if not re.fullmatch(r"[a-zA-Z0-9_-]{8,128}", name):
1139
+ raise ValueError("invalid preparation pin")
1140
+ path = cache.path("pins", name + ".json")
1141
+ if req.get("release"):
1142
+ try:
1143
+ os.unlink(path)
1144
+ except FileNotFoundError:
1145
+ pass
1146
+ else:
1147
+ if not HASH.fullmatch(req["revision"]):
1148
+ raise ValueError("invalid prepared revision")
1149
+ atomic(path, json.dumps({"revision": req["revision"], "at": int(time.time())}).encode())
1150
+ return {"ok": True}
1151
+
1152
+
1153
+ def prune(cache, req):
1154
+ """A bounded cache: a revision unused for a day (beyond the newest eight) leaves unless a pending start pins it, with
1155
+ its refs, markers, archive and media; unconsumed upload parts and requests leave after a day. Ages come from the
1156
+ files' contents and from when this pass first saw a file, never from file times (the project space can stamp them
1157
+ years ahead). Git drops what no ref reaches after its two-week window, long after a conversation copied what it
1158
+ borrowed."""
1159
+ now = int(time.time())
1160
+ listdir = lambda d: os.listdir(cache.path(d)) if os.path.isdir(cache.path(d)) else []
1161
+ with cache.lock():
1162
+ seen = read_json(cache.path("prune-seen.json"), {}) or {}
1163
+ current = {}
1164
+ for d in ("manifests", "extras", "chunks", "requests", "imports", "used", "published", "ready"):
1165
+ for name in listdir(d):
1166
+ if not os.path.islink(cache.path(d, name)) and ".tmp-" not in name:
1167
+ current[d + "/" + name] = seen.get(d + "/" + name, now)
1168
+ seen = current
1169
+ age = lambda key: now - seen.get(key, now)
1170
+ pins = set()
1171
+ for name in listdir("pins"):
1172
+ p = read_json(cache.path("pins", name))
1173
+ if p and now - p.get("at", 0) < 7 * 86400 and HASH.fullmatch(p.get("revision", "")):
1174
+ pins.add(p["revision"])
1175
+ else:
1176
+ try:
1177
+ os.unlink(cache.path("pins", name))
1178
+ except FileNotFoundError:
1179
+ pass
1180
+
1181
+ def last_used(rev):
1182
+ used = read_json(cache.path("used", rev + ".json"))
1183
+ return int(used["at"]) if used and "at" in used else now - age("manifests/" + rev + ".json")
1184
+ manifests = sorted((n[:-5] for n in listdir("manifests") if n.endswith(".json") and HASH.fullmatch(n[:-5])), key=last_used, reverse=True)
1185
+ keep, drop = set(), []
1186
+ for at, rev in enumerate(manifests):
1187
+ (keep.add(rev) if rev in pins or (at < KEEP_REVISIONS and now - last_used(rev) < REVISION_TTL) else drop.append(rev))
1188
+ extras_kept, media_kept = set(), set()
1189
+ for rev in keep:
1190
+ try:
1191
+ m = cache.manifest(rev)
1192
+ if m.get("extras_key"):
1193
+ extras_kept.add(m["extras_key"])
1194
+ media_kept |= {f["oid"] for f in m.get("files", [])}
1195
+ except (ValueError, FileNotFoundError):
1196
+ pass
1197
+ if os.path.isfile(os.path.join(cache.repo, "HEAD")) and drop:
1198
+ cache.git("update-ref", "--stdin", check=False, data="".join(
1199
+ "delete refs/shard/%s/%s\n" % (kind, r) for r in drop for kind in ("prepared", "upstream", "content")).encode())
1200
+ cache.git("pack-refs", "--all")
1201
+ refresh_state(cache)
1202
+ removed = 0
1203
+ for rev in drop:
1204
+ for d in ("ready", "published", "manifests", "imports", "used"):
1205
+ try:
1206
+ os.unlink(cache.path(d, rev + ".json"))
1207
+ removed += 1
1208
+ except FileNotFoundError:
1209
+ pass
1210
+ index = cache.media_index()
1211
+ if drop and index - media_kept:
1212
+ atomic(cache.path("media-index"), ("\n".join(sorted(index & media_kept)) + "\n").encode())
1213
+ for d, keepers in (("extras", extras_kept), ("chunks", set()), ("requests", set())):
1214
+ for name in listdir(d):
1215
+ if name not in keepers and age(d + "/" + name) > REVISION_TTL and not os.path.islink(cache.path(d, name)):
1216
+ os.unlink(cache.path(d, name))
1217
+ removed += 1
1218
+ atomic(cache.path("prune-seen.json"), json.dumps({k: v for k, v in seen.items() if os.path.lexists(cache.path(k))}).encode())
1219
+ if os.path.isfile(os.path.join(cache.repo, "HEAD")):
1220
+ packs = [n for n in os.listdir(os.path.join(cache.repo, "objects", "pack")) if n.endswith(".pack")]
1221
+ if len(packs) > 8:
1222
+ # Many packs slow every lookup over NFS. Most passes fold only the small recent packs (a geometric
1223
+ # progression keeps the large ones as they are); about weekly everything is rewritten, with what no
1224
+ # ref reaches in a cruft pack that Git drops after its usual two weeks.
1225
+ full = read_json(cache.path("repacked.json"), {}) or {}
1226
+ if now - int(full.get("at", 0)) > FULL_REPACK_EVERY:
1227
+ cache.git("-c", "pack.threads=2", "repack", "-a", "-d", "-q", "--cruft", "--cruft-expiration=2.weeks.ago", check=False)
1228
+ atomic(cache.path("repacked.json"), json.dumps({"at": now}).encode())
1229
+ else:
1230
+ cache.git("-c", "pack.threads=2", "repack", "-d", "-q", "--geometric=2", check=False)
1231
+ return {"revisions": len(keep), "dropped": len(drop), "removed_files": removed}
1232
+
1233
+
1234
+ def maybe_prune(cache):
1235
+ """A start's pass, after its reply, at most every PRUNE_EVERY: a project nobody selected stays bounded too. Its age
1236
+ is in the file, never its time (the project space can stamp file times years ahead)."""
1237
+ now = int(time.time())
1238
+ last = read_json(cache.path("pruned.json"), {}) or {}
1239
+ if now - int(last.get("at", 0)) < PRUNE_EVERY or not os.path.isdir(cache.root):
1240
+ return
1241
+ atomic(cache.path("pruned.json"), json.dumps({"at": now}).encode())
1242
+ os.nice(10)
1243
+ prune(cache, {})
1244
+
1245
+
1246
+ ACTIONS = {"probe": probe, "origin": origin, "import": import_, "stage": stage, "apply": apply, "materialize": materialize, "pin": pin, "prune": prune}
1247
+
1248
+
1249
+ def after_failed(action):
1250
+ """Work after the reply has no one to report to: its failures go to a small log in the VM (the next start repeats
1251
+ what failed: an import the cache lacks is sent again)."""
1252
+ import traceback
1253
+ try:
1254
+ log = os.path.join(os.environ.get("HOME") or "/home/user", ".cache", "shardflux", "prepared-after.log")
1255
+ os.makedirs(os.path.dirname(log), exist_ok=True)
1256
+ if os.path.isfile(log) and os.path.getsize(log) > 256 << 10:
1257
+ os.replace(log, log + ".1")
1258
+ with open(log, "a") as f:
1259
+ f.write("%s %s: %s\n" % (time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), action, traceback.format_exc()[-4000:]))
1260
+ except OSError:
1261
+ pass
1262
+
1263
+
1264
+ def main():
1265
+ action, root = sys.argv[1], sys.argv[2]
1266
+ if action not in ACTIONS or not root.startswith("/"):
1267
+ raise ValueError("unknown prepared repository operation")
1268
+ req = json.loads(sys.stdin.buffer.read() or b"{}")
1269
+ if req.get("request_file"):
1270
+ path = req["request_file"]
1271
+ if not path.startswith(root + "/requests/") or ".." in path.split("/"):
1272
+ raise ValueError("invalid request file")
1273
+ with open(path, "rb") as f:
1274
+ req = json.loads(f.read())
1275
+ os.unlink(path)
1276
+ cache = Cache(root)
1277
+ try:
1278
+ result = ACTIONS[action](cache, req)
1279
+ except Refused as r:
1280
+ result = {"error": r.reason, "message": str(r), **r.extra}
1281
+ # Tests (background false) run the follow-up work before the reply, so they observe its effects.
1282
+ if AFTER and (req.get("background") is False or (req.get("apply") or {}).get("background") is False):
1283
+ for job in AFTER:
1284
+ job()
1285
+ AFTER.clear()
1286
+ result.setdefault("timings", TIMINGS)
1287
+ sys.stdout.write(json.dumps(result) + "\n")
1288
+ sys.stdout.flush()
1289
+ if AFTER and os.fork() == 0:
1290
+ # The reply is out; this child outlives the exec (its own session, no pipe of the exec held open).
1291
+ os.setsid()
1292
+ null = os.open(os.devnull, os.O_RDWR)
1293
+ for fd in (0, 1, 2):
1294
+ os.dup2(null, fd)
1295
+ try:
1296
+ for job in AFTER:
1297
+ try:
1298
+ job()
1299
+ except Exception:
1300
+ after_failed(action)
1301
+ finally:
1302
+ os._exit(0)
1303
+
1304
+
1305
+ if __name__ == "__main__":
1306
+ main()