@shardflux/cli 0.20.0 → 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.
- package/CHANGELOG.md +214 -1
- package/README.md +215 -75
- package/dist/account-args.js +7 -2
- package/dist/account-commands.js +56 -106
- package/dist/account-pool.d.ts +19 -15
- package/dist/account-pool.js +47 -96
- package/dist/account-scope.d.ts +65 -0
- package/dist/account-scope.js +246 -0
- package/dist/acp-login.d.ts +4 -3
- package/dist/acp-login.js +7 -5
- package/dist/acp-models.js +20 -15
- package/dist/acp.js +66 -13
- package/dist/agent-commands.d.ts +4 -1
- package/dist/agent-commands.js +86 -32
- package/dist/agent-config-return.d.ts +46 -2
- package/dist/agent-config-return.js +110 -4
- package/dist/agent-credentials.js +7 -3
- package/dist/agent-repository.d.ts +16 -3
- package/dist/agent-repository.js +149 -74
- package/dist/agent-setup.d.ts +14 -3
- package/dist/agent-setup.js +78 -36
- package/dist/agent-target-commands.js +24 -13
- package/dist/args.d.ts +1 -1
- package/dist/args.js +94 -54
- package/dist/bin.js +5 -1
- package/dist/browser.d.ts +13 -2
- package/dist/browser.js +30 -14
- package/dist/claude-cloud-login.d.ts +24 -2
- package/dist/claude-cloud-login.js +151 -11
- package/dist/cli-proxy-forward.d.ts +2 -0
- package/dist/cli-proxy-forward.js +9 -2
- package/dist/cli-proxy-hub.d.ts +69 -25
- package/dist/cli-proxy-hub.js +231 -54
- package/dist/cli-proxy.d.ts +70 -1
- package/dist/cli-proxy.js +298 -32
- package/dist/client-settings.d.ts +39 -0
- package/dist/client-settings.js +91 -0
- package/dist/codex-cloud.d.ts +5 -0
- package/dist/codex-cloud.js +88 -17
- package/dist/command-center-actions.js +5 -1
- package/dist/commands.js +171 -22
- package/dist/conversation-commands.d.ts +5 -0
- package/dist/conversation-commands.js +8 -5
- package/dist/conversation-terminal.d.ts +10 -2
- package/dist/conversation-terminal.js +77 -13
- package/dist/conversations.d.ts +73 -1
- package/dist/conversations.js +257 -23
- package/dist/device-bridge.d.ts +1 -1
- package/dist/device-bridge.js +6 -4
- package/dist/device-helper.js +43 -22
- package/dist/errors.d.ts +27 -1
- package/dist/errors.js +45 -5
- package/dist/filesystem-commands.d.ts +16 -9
- package/dist/filesystem-commands.js +108 -54
- package/dist/format.d.ts +2 -1
- package/dist/format.js +10 -1
- package/dist/handlers.js +3 -1
- package/dist/initial-prompt.d.ts +8 -0
- package/dist/initial-prompt.js +43 -1
- package/dist/large-media.d.ts +14 -0
- package/dist/large-media.js +100 -0
- package/dist/linux-terminal.d.ts +5 -3
- package/dist/linux-terminal.js +6 -20
- package/dist/local-sessions.d.ts +26 -2
- package/dist/local-sessions.js +113 -24
- package/dist/machine-setup.d.ts +11 -1
- package/dist/machine-setup.js +69 -22
- package/dist/machine-status.d.ts +67 -0
- package/dist/machine-status.js +140 -0
- package/dist/main.js +11 -13
- package/dist/move-worktrees-restore.py +144 -0
- package/dist/move-worktrees.d.ts +63 -0
- package/dist/move-worktrees.js +201 -0
- package/dist/move.d.ts +10 -1
- package/dist/move.js +116 -16
- package/dist/onboarding.d.ts +11 -7
- package/dist/onboarding.js +59 -27
- package/dist/prepared-repository.d.ts +202 -0
- package/dist/prepared-repository.js +860 -0
- package/dist/prepared-repository.py +1306 -0
- package/dist/project-preparation.d.ts +48 -0
- package/dist/project-preparation.js +304 -0
- package/dist/project-sync-manifest.d.ts +4 -0
- package/dist/project-sync-manifest.js +5 -1
- package/dist/project-sync-transfer.js +1 -1
- package/dist/project-sync.d.ts +19 -4
- package/dist/project-sync.js +31 -8
- package/dist/project-template-setup.d.ts +33 -1
- package/dist/project-template-setup.js +59 -2
- package/dist/secret-ask.d.ts +72 -0
- package/dist/secret-ask.js +351 -0
- package/dist/seed-phases.d.ts +8 -1
- package/dist/seed-phases.js +17 -2
- package/dist/setup-cache.d.ts +7 -0
- package/dist/setup-cache.js +32 -0
- package/dist/t3-launch.js +1 -4
- package/dist/t3-mode.d.ts +8 -2
- package/dist/t3-mode.js +19 -4
- package/dist/t3-phone.d.ts +4 -2
- package/dist/t3-phone.js +12 -4
- package/dist/terminal-apps.d.ts +157 -0
- package/dist/terminal-apps.js +561 -0
- package/dist/terminal-onboarding.js +6 -1
- package/dist/this-computer.d.ts +11 -0
- package/dist/this-computer.js +36 -0
- package/dist/thread-on-computer.js +4 -1
- package/dist/update.js +11 -3
- package/dist/worktree-twin.d.ts +10 -2
- package/dist/worktree-twin.js +27 -9
- 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()
|