@shardflux/cli 0.15.2 → 0.16.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 +82 -12
- package/README.md +50 -3
- package/dist/acp-models.js +5 -1
- package/dist/agent-commands.d.ts +8 -2
- package/dist/agent-commands.js +23 -11
- package/dist/agent-target-commands.d.ts +7 -2
- package/dist/agent-target-commands.js +12 -7
- package/dist/agent-targets.d.ts +3 -0
- package/dist/agent-targets.js +4 -1
- package/dist/agent-terminal.d.ts +8 -0
- package/dist/agent-terminal.js +35 -2
- package/dist/args.d.ts +1 -1
- package/dist/args.js +26 -3
- package/dist/codex-cloud.d.ts +2 -0
- package/dist/codex-cloud.js +2 -0
- package/dist/command-center-actions.js +9 -0
- package/dist/commands.d.ts +22 -0
- package/dist/commands.js +54 -14
- package/dist/conversation-commands.js +6 -0
- package/dist/conversation-terminal.js +10 -1
- package/dist/conversations.d.ts +13 -1
- package/dist/conversations.js +41 -5
- package/dist/device-helper.js +9 -2
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +11 -1
- package/dist/files-mirror.d.ts +164 -0
- package/dist/files-mirror.js +761 -0
- package/dist/handlers.js +3 -1
- package/dist/main.js +9 -2
- package/dist/registry.d.ts +2 -42
- package/dist/registry.js +6 -90
- package/dist/thread-on-computer.d.ts +35 -0
- package/dist/thread-on-computer.js +200 -0
- package/dist/wait-status.d.ts +22 -0
- package/dist/wait-status.js +59 -0
- package/dist/worktree-twin-engine.d.ts +95 -0
- package/dist/worktree-twin-engine.js +402 -0
- package/dist/worktree-twin-land.py +331 -0
- package/dist/worktree-twin.d.ts +296 -0
- package/dist/worktree-twin.js +718 -0
- package/package.json +3 -3
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Shardflux landing: runs in a conversation's VM, shipped and started by the shard CLI (worktree-twin.ts).
|
|
2
|
+
|
|
3
|
+
Input: one JSON object on stdin. Output: one JSON object on stdout (exit 0; "state" says what happened).
|
|
4
|
+
|
|
5
|
+
op "land" brings the conversation's committed work (its branch tip T) onto the user's branch tip B, as captured in the
|
|
6
|
+
user's twin W (a snapshot commit whose first parent is B and whose tree is the user's working state):
|
|
7
|
+
B' (tip) fast-forward to T when B is an ancestor of T and B..T holds no snapshot commit; otherwise the
|
|
8
|
+
conversation's first-parent commits since its last landed point replayed onto B (author and message
|
|
9
|
+
kept); if a replay step conflicts, one squash merge; if that conflicts too: state "conflict".
|
|
10
|
+
R (result) merge-tree --merge-base B W B': the user's working state plus the conversation's changes. Overlapping
|
|
11
|
+
hunks keep the user's text (-X ours); any other conflicting path keeps the user's version.
|
|
12
|
+
mode "pr" pushes B' to origin as the conversation branch and opens a pull request instead (gh).
|
|
13
|
+
op "confirm" records T as landed (refs/shard/land/upto here) and, after the files are on the user's disk, copies the
|
|
14
|
+
twin, tip, result and upto to the project hub (refs/shard/twin/<device>/<worktree>, refs/shard/land/<name>/*). The
|
|
15
|
+
hub is on shared storage, so "land" never touches it: it only reads the hub's upto when nothing closer knows it.
|
|
16
|
+
Small bundles travel inline (base64 in the JSON) instead of as files.
|
|
17
|
+
|
|
18
|
+
Only Git plumbing: the conversation's worktree, index and HEAD are never changed.
|
|
19
|
+
"""
|
|
20
|
+
import base64
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
SNAPSHOT = 'Shardflux-Snapshot:'
|
|
29
|
+
INLINE_BYTES = 512 << 10
|
|
30
|
+
STARTED = time.monotonic()
|
|
31
|
+
PHASES = {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def phase(name):
|
|
35
|
+
PHASES[name] = int((time.monotonic() - STARTED) * 1000)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Fail(Exception):
|
|
39
|
+
def __init__(self, reason, message):
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.reason = reason
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def git(cwd, *args, env=None, data=None, ok=(0,)):
|
|
45
|
+
full = dict(os.environ, GIT_TERMINAL_PROMPT='0', LC_ALL='C')
|
|
46
|
+
if env:
|
|
47
|
+
full.update(env)
|
|
48
|
+
p = subprocess.run(['git', '-c', 'core.hooksPath=/dev/null', *args], cwd=cwd, env=full, input=data,
|
|
49
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
50
|
+
if p.returncode not in ok:
|
|
51
|
+
tail = p.stderr.decode('utf-8', 'replace').strip()[-400:]
|
|
52
|
+
raise Fail('git', 'git %s failed (%d): %s' % (args[0], p.returncode, tail))
|
|
53
|
+
return p
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def text(cwd, *args, **kw):
|
|
57
|
+
return git(cwd, *args, **kw).stdout.decode('utf-8', 'surrogateescape').strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def commit(cwd, rev):
|
|
61
|
+
p = git(cwd, 'rev-parse', '-q', '--verify', rev + '^{commit}', ok=(0, 1))
|
|
62
|
+
return p.stdout.decode().strip() if p.returncode == 0 else ''
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def ancestor(cwd, a, b):
|
|
66
|
+
return git(cwd, 'merge-base', '--is-ancestor', a, b, ok=(0, 1)).returncode == 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def tree_of(cwd, rev):
|
|
70
|
+
return text(cwd, 'rev-parse', rev + '^{tree}')
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def merge_tree(cwd, base, ours, theirs, strategy=None):
|
|
74
|
+
args = ['merge-tree', '--write-tree', '--name-only', '--no-messages', '-z', '--merge-base=' + base]
|
|
75
|
+
if strategy:
|
|
76
|
+
args += ['-X', strategy]
|
|
77
|
+
p = git(cwd, *args, ours, theirs, ok=(0, 1))
|
|
78
|
+
parts = p.stdout.decode('utf-8', 'surrogateescape').split('\0')
|
|
79
|
+
return parts[0].strip(), (sorted({x for x in parts[1:] if x}) if p.returncode == 1 else [])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def message_of(cwd, rev):
|
|
83
|
+
raw = git(cwd, 'cat-file', 'commit', rev).stdout
|
|
84
|
+
return raw.split(b'\n\n', 1)[1] if b'\n\n' in raw else b''
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def author_of(cwd, rev):
|
|
88
|
+
name, email, date = text(cwd, 'log', '-1', '--format=%an%x00%ae%x00%ad', '--date=raw', rev).split('\0')
|
|
89
|
+
return {'GIT_AUTHOR_NAME': name, 'GIT_AUTHOR_EMAIL': email, 'GIT_AUTHOR_DATE': date}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def committer(cwd):
|
|
93
|
+
name = git(cwd, 'config', 'user.name', ok=(0, 1)).stdout.decode().strip()
|
|
94
|
+
email = git(cwd, 'config', 'user.email', ok=(0, 1)).stdout.decode().strip()
|
|
95
|
+
return {'GIT_COMMITTER_NAME': name or 'Shardflux', 'GIT_COMMITTER_EMAIL': email or 'landing@shardflux.dev'}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def commit_tree(cwd, tree, parents, message, env):
|
|
99
|
+
args = ['commit-tree', tree]
|
|
100
|
+
for p in parents:
|
|
101
|
+
args += ['-p', p]
|
|
102
|
+
return text(cwd, *args, '-F', '-', env=env, data=message)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def snapshot(message):
|
|
106
|
+
return any(line.startswith(SNAPSHOT) for line in message.decode('utf-8', 'replace').splitlines())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def replay(cwd, onto, commits, who):
|
|
110
|
+
cur = onto
|
|
111
|
+
for c in commits:
|
|
112
|
+
message = message_of(cwd, c)
|
|
113
|
+
if snapshot(message):
|
|
114
|
+
continue
|
|
115
|
+
parent = commit(cwd, c + '^1')
|
|
116
|
+
if not parent:
|
|
117
|
+
return None
|
|
118
|
+
tree, conflicts = merge_tree(cwd, parent, cur, c)
|
|
119
|
+
if conflicts:
|
|
120
|
+
return None
|
|
121
|
+
if tree == tree_of(cwd, cur):
|
|
122
|
+
continue # already there (as rebase drops a commit that became empty)
|
|
123
|
+
cur = commit_tree(cwd, tree, [cur], message, dict(who, **author_of(cwd, c)))
|
|
124
|
+
return cur
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def user_side(cwd, upto, base, tip, who):
|
|
128
|
+
"""The user's state the conversation already contains, as a merge base for squashing it onto base.
|
|
129
|
+
|
|
130
|
+
Normally that is upto (its starting snapshot or last landed point). When the conversation merged the user's branch
|
|
131
|
+
(resolving a conflict), it also contains that branch's commit mb: the base is then upto and mb merged (a commit
|
|
132
|
+
made here, never pushed), so neither the user's commits nor the uncommitted edits it started from count as the
|
|
133
|
+
conversation's change. None when the two cannot be merged cleanly."""
|
|
134
|
+
p = git(cwd, 'merge-base', base, tip, ok=(0, 1))
|
|
135
|
+
mb = p.stdout.decode().strip()
|
|
136
|
+
if not mb or ancestor(cwd, mb, upto):
|
|
137
|
+
return upto
|
|
138
|
+
if ancestor(cwd, upto, mb):
|
|
139
|
+
return mb
|
|
140
|
+
p = git(cwd, 'merge-base', upto, mb, ok=(0, 1))
|
|
141
|
+
common = p.stdout.decode().strip()
|
|
142
|
+
if not common:
|
|
143
|
+
return None
|
|
144
|
+
tree, conflicts = merge_tree(cwd, common, mb, upto)
|
|
145
|
+
if conflicts:
|
|
146
|
+
return None
|
|
147
|
+
return commit_tree(cwd, tree, [mb, upto], b'Shardflux: user state\n', who)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def override(cwd, tree, source, paths):
|
|
151
|
+
"""tree with each of paths (files or directories) taken from source, the user's working state, or removed when
|
|
152
|
+
source lacks it."""
|
|
153
|
+
fd, index = tempfile.mkstemp(prefix='shard-land-index-')
|
|
154
|
+
os.close(fd)
|
|
155
|
+
os.unlink(index)
|
|
156
|
+
env = {'GIT_INDEX_FILE': index}
|
|
157
|
+
try:
|
|
158
|
+
git(cwd, 'read-tree', tree, env=env)
|
|
159
|
+
git(cwd, 'rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', *paths, env=env)
|
|
160
|
+
listing = git(cwd, 'ls-tree', '-r', '-z', '--full-tree', source, '--', *paths).stdout
|
|
161
|
+
lines = b''.join(item.replace(b' blob ', b' ', 1).replace(b' commit ', b' ', 1) + b'\0' for item in listing.split(b'\0') if item)
|
|
162
|
+
if lines:
|
|
163
|
+
git(cwd, 'update-index', '-z', '--index-info', env=env, data=lines)
|
|
164
|
+
return text(cwd, 'write-tree', env=env)
|
|
165
|
+
finally:
|
|
166
|
+
if os.path.exists(index):
|
|
167
|
+
os.unlink(index)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def changed(cwd, a, b):
|
|
171
|
+
out = git(cwd, 'diff-tree', '-r', '-z', '--name-only', '--no-renames', a, b).stdout.decode('utf-8', 'surrogateescape')
|
|
172
|
+
return sorted({x for x in out.split('\0') if x})
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def hub_ref(cwd, hub, ref):
|
|
176
|
+
if not hub or not os.path.isdir(hub):
|
|
177
|
+
return ''
|
|
178
|
+
# One ref read on the shared storage (ls-remote would start upload-pack and list every ref: most of a second).
|
|
179
|
+
p = git(cwd, '--git-dir=' + hub, 'rev-parse', '-q', '--verify', ref, ok=(0, 1, 128))
|
|
180
|
+
return p.stdout.decode().strip() if p.returncode == 0 else ''
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def land(a):
|
|
184
|
+
cwd, hub, name = a['repo'], a.get('hub') or '', a['name']
|
|
185
|
+
if not os.path.isdir(hub):
|
|
186
|
+
hub = ''
|
|
187
|
+
branch = 'refs/heads/' + a['branch']
|
|
188
|
+
head = git(cwd, 'symbolic-ref', '-q', 'HEAD', ok=(0, 1)).stdout.decode().strip()
|
|
189
|
+
if head != branch:
|
|
190
|
+
raise Fail('not_on_branch', 'the conversation worktree is on %s, not %s' % (head or 'a detached HEAD', branch))
|
|
191
|
+
tip = commit(cwd, 'HEAD')
|
|
192
|
+
phase('start')
|
|
193
|
+
if a.get('bundle_b64'):
|
|
194
|
+
fd, path = tempfile.mkstemp(prefix='shard-twin-', suffix='.bundle')
|
|
195
|
+
with os.fdopen(fd, 'wb') as f:
|
|
196
|
+
f.write(base64.b64decode(a['bundle_b64']))
|
|
197
|
+
a['bundle'] = path
|
|
198
|
+
if a.get('bundle'):
|
|
199
|
+
# A VM without an earlier landing of this conversation lacks the bundle's prerequisites: the CLI then sends
|
|
200
|
+
# everything since the conversation's base.
|
|
201
|
+
try:
|
|
202
|
+
p = git(cwd, 'fetch', '-q', '--no-tags', a['bundle'], '+%s:refs/shard/twin-in' % a['twin_ref'], ok=(0, 1, 128))
|
|
203
|
+
finally:
|
|
204
|
+
if a.get('bundle_b64'):
|
|
205
|
+
os.unlink(a['bundle'])
|
|
206
|
+
phase('twin_in')
|
|
207
|
+
if p.returncode != 0:
|
|
208
|
+
reason = 'prerequisites' if b'prerequisite' in p.stderr else 'git'
|
|
209
|
+
raise Fail(reason, 'twin bundle: %s' % p.stderr.decode('utf-8', 'replace').strip()[-400:])
|
|
210
|
+
twin = commit(cwd, a['twin'])
|
|
211
|
+
if not twin:
|
|
212
|
+
raise Fail('twin_missing', 'the twin %s did not arrive' % a['twin'])
|
|
213
|
+
base = commit(cwd, twin + '^1')
|
|
214
|
+
if not base:
|
|
215
|
+
raise Fail('twin_unborn', 'the twin has no branch commit')
|
|
216
|
+
git(cwd, 'update-ref', 'refs/shard/twin-in', twin)
|
|
217
|
+
result = {'base': base, 'twin': twin, 'tip': base, 'result': twin, 'upto': tip, 'conversation_tip': tip}
|
|
218
|
+
upto = ''
|
|
219
|
+
candidates = (lambda: a.get('upto') or '', lambda: commit(cwd, 'refs/shard/land/upto'),
|
|
220
|
+
lambda: hub_ref(cwd, hub, 'refs/shard/land/%s/upto' % name), lambda: a.get('base') or '')
|
|
221
|
+
for get in candidates:
|
|
222
|
+
candidate = get()
|
|
223
|
+
if candidate and commit(cwd, candidate) and ancestor(cwd, candidate, tip):
|
|
224
|
+
upto = commit(cwd, candidate)
|
|
225
|
+
break
|
|
226
|
+
if not upto:
|
|
227
|
+
upto = text(cwd, 'merge-base', tip, base) if git(cwd, 'merge-base', tip, base, ok=(0, 1)).returncode == 0 else ''
|
|
228
|
+
if not upto:
|
|
229
|
+
raise Fail('unrelated', 'the conversation shares no history with the twin')
|
|
230
|
+
if upto == tip or ancestor(cwd, tip, base):
|
|
231
|
+
result['state'] = 'noop'
|
|
232
|
+
return result
|
|
233
|
+
phase('upto')
|
|
234
|
+
who = committer(cwd)
|
|
235
|
+
log = git(cwd, 'log', '--format=%H%x1f%B%x1e', '%s..%s' % (base, tip)).stdout if ancestor(cwd, base, tip) else b''
|
|
236
|
+
commits = [x for x in text(cwd, 'rev-list', '--reverse', '--first-parent', tip, '^' + upto, '^' + base).split('\n') if x]
|
|
237
|
+
if ancestor(cwd, base, tip) and not any(snapshot(e.split(b'\x1f', 1)[1]) for e in log.split(b'\x1e') if b'\x1f' in e):
|
|
238
|
+
new, method = tip, 'fast-forward'
|
|
239
|
+
else:
|
|
240
|
+
new, method = replay(cwd, base, commits, who), 'replay'
|
|
241
|
+
if new is None:
|
|
242
|
+
mb = user_side(cwd, upto, base, tip, who)
|
|
243
|
+
tree, conflicts = merge_tree(cwd, mb, base, tip) if mb else ('', ['(user branch)'])
|
|
244
|
+
if conflicts:
|
|
245
|
+
result.update(state='conflict', stage='commits', conflicts=conflicts)
|
|
246
|
+
return result
|
|
247
|
+
subjects = []
|
|
248
|
+
for c in commits:
|
|
249
|
+
m = message_of(cwd, c)
|
|
250
|
+
if not snapshot(m) and not commit(cwd, c + '^2'):
|
|
251
|
+
subjects.append('- ' + m.decode('utf-8', 'replace').split('\n', 1)[0])
|
|
252
|
+
body = '%s\n\n%s\n\nShardflux-Conversation: %s\n' % (a.get('title') or a['branch'], '\n'.join(subjects), a.get('conversation_id', ''))
|
|
253
|
+
new, method = commit_tree(cwd, tree, [base], body.encode(), dict(who, **author_of(cwd, tip))), 'squash'
|
|
254
|
+
phase('commits')
|
|
255
|
+
result.update(tip=new, method=method)
|
|
256
|
+
if new == base or tree_of(cwd, new) == tree_of(cwd, base):
|
|
257
|
+
result['state'] = 'noop'
|
|
258
|
+
return result
|
|
259
|
+
if a.get('mode') == 'pr':
|
|
260
|
+
git(cwd, 'push', '-q', '--force', 'origin', '%s:refs/heads/%s' % (new, a['branch']))
|
|
261
|
+
url = subprocess.run(['gh', 'pr', 'view', a['branch'], '--json', 'url,state', '--jq', 'select(.state == "OPEN") | .url'],
|
|
262
|
+
cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout.decode().strip()
|
|
263
|
+
if not url:
|
|
264
|
+
p = subprocess.run(['gh', 'pr', 'create', '--base', a['into'], '--head', a['branch'], '--title', a.get('title') or a['branch'],
|
|
265
|
+
'--body', a.get('summary') or ''], cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
266
|
+
if p.returncode != 0:
|
|
267
|
+
raise Fail('pr', 'gh pr create failed: %s' % p.stderr.decode('utf-8', 'replace').strip()[-400:])
|
|
268
|
+
url = p.stdout.decode().strip().split('\n')[-1]
|
|
269
|
+
result.update(state='pr', url=url)
|
|
270
|
+
return result
|
|
271
|
+
tree, conflicts = merge_tree(cwd, base, twin, new)
|
|
272
|
+
shadowed = list(conflicts)
|
|
273
|
+
if conflicts:
|
|
274
|
+
tree, rest = merge_tree(cwd, base, twin, new, 'ours')
|
|
275
|
+
if rest:
|
|
276
|
+
tree = override(cwd, tree, twin, rest)
|
|
277
|
+
trailers = 'Shardflux-Conversation: %s\nShardflux-Into: %s\nShardflux-Twin: %s\nShardflux-Upto: %s\n' % (a.get('conversation_id', ''), a['into'], twin, tip)
|
|
278
|
+
res = commit_tree(cwd, tree, [new], ('Shardflux landing result\n\n' + trailers).encode(), dict(who, GIT_AUTHOR_NAME='Shardflux', GIT_AUTHOR_EMAIL='landing@shardflux.dev'))
|
|
279
|
+
git(cwd, 'update-ref', 'refs/shard/land/tip', new)
|
|
280
|
+
git(cwd, 'update-ref', 'refs/shard/land/result', res)
|
|
281
|
+
phase('result')
|
|
282
|
+
out = a['out']
|
|
283
|
+
git(cwd, 'bundle', 'create', '-q', out, 'refs/shard/land/tip', 'refs/shard/land/result', '--not', base)
|
|
284
|
+
phase('bundle')
|
|
285
|
+
size = os.path.getsize(out)
|
|
286
|
+
result.update(state='ready', result=res, files=changed(cwd, twin, res), shadowed=shadowed, bundle=out, bundle_bytes=size)
|
|
287
|
+
if size <= INLINE_BYTES:
|
|
288
|
+
with open(out, 'rb') as f:
|
|
289
|
+
result['bundle_b64'] = base64.b64encode(f.read()).decode()
|
|
290
|
+
os.unlink(out)
|
|
291
|
+
result['bundle'] = None
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def confirm(a):
|
|
296
|
+
cwd, hub = a['repo'], a.get('hub') or ''
|
|
297
|
+
upto = commit(cwd, a['upto'])
|
|
298
|
+
if not upto:
|
|
299
|
+
raise Fail('unknown_commit', 'landed commit %s is not in this repository' % a['upto'])
|
|
300
|
+
git(cwd, 'update-ref', 'refs/shard/land/upto', upto)
|
|
301
|
+
if a.get('into'):
|
|
302
|
+
# The guest's turn-end report keeps the conversation landed while HEAD is this commit.
|
|
303
|
+
path = text(cwd, 'rev-parse', '--git-path', 'shard-landed')
|
|
304
|
+
path = path if os.path.isabs(path) else os.path.join(cwd, path)
|
|
305
|
+
with open(path + '.tmp', 'w') as f:
|
|
306
|
+
f.write('%s %s\n' % (upto, a['into']))
|
|
307
|
+
os.replace(path + '.tmp', path)
|
|
308
|
+
phase('recorded')
|
|
309
|
+
if hub and os.path.isdir(hub):
|
|
310
|
+
refs = ['+%s:refs/shard/land/%s/upto' % (upto, a['name'])]
|
|
311
|
+
for sha, ref in ((a.get('twin'), a.get('hub_twin_ref')), (a.get('tip'), 'refs/shard/land/%s/tip' % a['name']),
|
|
312
|
+
(a.get('result'), 'refs/shard/land/%s/result' % a['name'])):
|
|
313
|
+
if sha and ref and commit(cwd, sha):
|
|
314
|
+
refs.append('+%s:%s' % (sha, ref))
|
|
315
|
+
git(cwd, 'push', '-q', '--atomic', '--no-verify', hub, *refs)
|
|
316
|
+
phase('hub')
|
|
317
|
+
return {'state': 'confirmed', 'upto': upto, 'hub': bool(hub and os.path.isdir(hub))}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def main():
|
|
321
|
+
a = json.loads(sys.stdin.read())
|
|
322
|
+
try:
|
|
323
|
+
out = confirm(a) if a.get('op') == 'confirm' else land(a)
|
|
324
|
+
except Fail as e:
|
|
325
|
+
out = {'state': 'error', 'reason': e.reason, 'message': str(e)}
|
|
326
|
+
out['phases'] = PHASES
|
|
327
|
+
sys.stdout.write(json.dumps(out) + '\n')
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
if __name__ == '__main__':
|
|
331
|
+
main()
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import type { CellClient } from '@shardflux/sdk';
|
|
2
|
+
import { AgentStateStore } from './agent-state.js';
|
|
3
|
+
/** Files above this stay at HEAD's version in a twin (the bound the seed copy already uses). */
|
|
4
|
+
export declare const TWIN_MAX_FILE_BYTES: number;
|
|
5
|
+
/** A file the landing rewrites must be untouched this long first. */
|
|
6
|
+
export declare const QUIET_MS = 2000;
|
|
7
|
+
export declare const HUB_PATH = "/shardflux/project/git/hub.git";
|
|
8
|
+
export interface GitResult {
|
|
9
|
+
code: number;
|
|
10
|
+
stdout: Buffer;
|
|
11
|
+
stderr: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class TwinGitError extends Error {
|
|
14
|
+
readonly code: number;
|
|
15
|
+
readonly stderr: string;
|
|
16
|
+
constructor(args: string[], result: GitResult);
|
|
17
|
+
}
|
|
18
|
+
export interface GitOptions {
|
|
19
|
+
env?: Record<string, string>;
|
|
20
|
+
input?: string | Uint8Array;
|
|
21
|
+
ok?: number[];
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
/** Git with no hooks, no fsmonitor and no optional locks; binary-safe output. */
|
|
25
|
+
export declare function git(cwd: string, args: string[], options?: GitOptions): Promise<GitResult>;
|
|
26
|
+
export declare function gitText(cwd: string, args: string[], options?: GitOptions): Promise<string>;
|
|
27
|
+
export interface WorktreeInfo {
|
|
28
|
+
root: string;
|
|
29
|
+
gitDir: string;
|
|
30
|
+
commonDir: string;
|
|
31
|
+
head: string | null;
|
|
32
|
+
branch: string | null;
|
|
33
|
+
}
|
|
34
|
+
export declare function inspectWorktree(root: string): Promise<WorktreeInfo>;
|
|
35
|
+
/** The user's own Git operation in progress: the twin waits for it. */
|
|
36
|
+
export declare function pauseReason(info: Pick<WorktreeInfo, 'gitDir' | 'commonDir'>): string | null;
|
|
37
|
+
export interface WorktreeEntry {
|
|
38
|
+
root: string;
|
|
39
|
+
head: string | null;
|
|
40
|
+
branch: string | null;
|
|
41
|
+
}
|
|
42
|
+
export declare function listWorktrees(anyRoot: string): Promise<WorktreeEntry[]>;
|
|
43
|
+
export declare const worktreeId: (root: string) => string;
|
|
44
|
+
export declare const twinRef: (id: string) => string;
|
|
45
|
+
export declare const hubTwinRef: (device: string, id: string) => string;
|
|
46
|
+
export interface Snapshot {
|
|
47
|
+
commit: string;
|
|
48
|
+
tree: string;
|
|
49
|
+
base: string;
|
|
50
|
+
branch: string | null;
|
|
51
|
+
root: string;
|
|
52
|
+
id: string;
|
|
53
|
+
held: string[];
|
|
54
|
+
headTree: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* One snapshot commit of the worktree's working state (what `git add -A` sees; `.shard/` and files above the size gate
|
|
58
|
+
* left at HEAD's version), first parent HEAD. Kept as refs/shard/twin/<worktree id>; an unchanged state reuses it.
|
|
59
|
+
*/
|
|
60
|
+
export declare function snapshotWorktree(info: WorktreeInfo, options: {
|
|
61
|
+
device: string;
|
|
62
|
+
stateDir: string;
|
|
63
|
+
maxFileBytes?: number;
|
|
64
|
+
now?: Date;
|
|
65
|
+
signal?: AbortSignal;
|
|
66
|
+
}): Promise<Snapshot>;
|
|
67
|
+
/** A twin for a branch checked out nowhere: its own tree, first parent its tip. */
|
|
68
|
+
export declare function refTwin(repo: string, branch: string, device: string, now?: Date): Promise<Snapshot>;
|
|
69
|
+
export interface LandingPlan {
|
|
70
|
+
/** The worktree where the branch is checked out; null lands the branch ref only. */
|
|
71
|
+
worktree: WorktreeInfo | null;
|
|
72
|
+
repo: string;
|
|
73
|
+
branch: string;
|
|
74
|
+
base: string;
|
|
75
|
+
twin: string;
|
|
76
|
+
tip: string;
|
|
77
|
+
result: string;
|
|
78
|
+
}
|
|
79
|
+
export interface Applied {
|
|
80
|
+
state: 'applied';
|
|
81
|
+
files: string[];
|
|
82
|
+
raced: string[];
|
|
83
|
+
backup: string | null;
|
|
84
|
+
at: number;
|
|
85
|
+
}
|
|
86
|
+
export type ApplyOutcome = Applied | {
|
|
87
|
+
state: 'paused';
|
|
88
|
+
reason: string;
|
|
89
|
+
} | {
|
|
90
|
+
state: 'moved';
|
|
91
|
+
reason: string;
|
|
92
|
+
paths?: string[];
|
|
93
|
+
} | {
|
|
94
|
+
state: 'busy';
|
|
95
|
+
waitMs: number;
|
|
96
|
+
};
|
|
97
|
+
export interface ApplyOptions {
|
|
98
|
+
backupRoot: string;
|
|
99
|
+
quietMs?: number;
|
|
100
|
+
now?: () => number;
|
|
101
|
+
label: {
|
|
102
|
+
conversation_id: string;
|
|
103
|
+
name: string;
|
|
104
|
+
title: string;
|
|
105
|
+
};
|
|
106
|
+
/** Called just before each file's final check (tests reproduce a save in that gap). */
|
|
107
|
+
beforeWrite?: (path: string) => void;
|
|
108
|
+
}
|
|
109
|
+
/** Prune twin backups older than 14 days, then the oldest while the total exceeds 1 GiB. */
|
|
110
|
+
export declare function pruneBackups(root: string, now?: number, maxDays?: number, maxBytes?: number): string[];
|
|
111
|
+
export interface BackupManifest {
|
|
112
|
+
version: 1;
|
|
113
|
+
created_at: string;
|
|
114
|
+
worktree: string;
|
|
115
|
+
branch: string;
|
|
116
|
+
conversation_id: string;
|
|
117
|
+
name: string;
|
|
118
|
+
title: string;
|
|
119
|
+
from: string;
|
|
120
|
+
to: string;
|
|
121
|
+
twin: string;
|
|
122
|
+
result: string;
|
|
123
|
+
files: {
|
|
124
|
+
path: string;
|
|
125
|
+
action: 'modify' | 'delete' | 'create';
|
|
126
|
+
before: string | null;
|
|
127
|
+
after: string | null;
|
|
128
|
+
saved: boolean;
|
|
129
|
+
}[];
|
|
130
|
+
}
|
|
131
|
+
/** Writes each backed-up file back (the current content is backed up first); files the landing created are removed. */
|
|
132
|
+
export declare function restoreBackup(backup: string, options: {
|
|
133
|
+
backupRoot: string;
|
|
134
|
+
now?: () => number;
|
|
135
|
+
}): Promise<{
|
|
136
|
+
restored: string[];
|
|
137
|
+
backup: string;
|
|
138
|
+
}>;
|
|
139
|
+
/**
|
|
140
|
+
* Moves the branch from plan.base to plan.tip and the worktree from plan.twin's tree to plan.result's tree, only where
|
|
141
|
+
* the disk still holds what the twin captured and has been quiet. Never drops a user's edit: a changed file means
|
|
142
|
+
* 'moved' (redo the round), a running Git operation 'paused', a recent write 'busy'.
|
|
143
|
+
*/
|
|
144
|
+
export declare function applyLanding(plan: LandingPlan, options: ApplyOptions): Promise<ApplyOutcome>;
|
|
145
|
+
/** Index entries that equal the old tip move to the new tip; entries the user staged differently are merged three ways. */
|
|
146
|
+
export declare function refreshIndex(root: string, base: string, tip: string, attempts?: number): Promise<void>;
|
|
147
|
+
export type TwinCell = {
|
|
148
|
+
exec: Pick<CellClient['exec'], 'run'>;
|
|
149
|
+
files: Pick<CellClient['files'], 'read' | 'write' | 'remove' | 'stat'>;
|
|
150
|
+
};
|
|
151
|
+
export interface RoundConversation {
|
|
152
|
+
id: string;
|
|
153
|
+
name: string;
|
|
154
|
+
title: string;
|
|
155
|
+
summary: string;
|
|
156
|
+
branch: string;
|
|
157
|
+
base_commit: string;
|
|
158
|
+
worktree_path: string;
|
|
159
|
+
}
|
|
160
|
+
export interface RoundInput {
|
|
161
|
+
conversation: RoundConversation;
|
|
162
|
+
into: string;
|
|
163
|
+
mode: 'auto' | 'pr';
|
|
164
|
+
device: string;
|
|
165
|
+
repo: string;
|
|
166
|
+
worktree: WorktreeInfo | null;
|
|
167
|
+
stateDir: string;
|
|
168
|
+
backupRoot: string;
|
|
169
|
+
/** The conversation's last landed tip known here (the ledger), and local commits the VM is known to have. */
|
|
170
|
+
upto?: string;
|
|
171
|
+
known?: string[];
|
|
172
|
+
hub?: string;
|
|
173
|
+
vmTmp?: string;
|
|
174
|
+
quietMs?: number;
|
|
175
|
+
signal?: AbortSignal;
|
|
176
|
+
now?: () => number;
|
|
177
|
+
/**
|
|
178
|
+
* The conversation's VM is gone (it ended while this computer was away): the round runs in another VM of the project
|
|
179
|
+
* that reads the project hub, on a temporary repository holding the conversation's branch from the hub. The hub is
|
|
180
|
+
* only read; repoRemote lets `pr` push to the repository's origin.
|
|
181
|
+
*/
|
|
182
|
+
fromHub?: {
|
|
183
|
+
repoRemote?: string;
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
export interface LandResult {
|
|
187
|
+
state: 'ready' | 'noop' | 'conflict' | 'pr' | 'error';
|
|
188
|
+
base?: string;
|
|
189
|
+
twin?: string;
|
|
190
|
+
tip?: string;
|
|
191
|
+
result?: string;
|
|
192
|
+
upto?: string;
|
|
193
|
+
conversation_tip?: string;
|
|
194
|
+
method?: string;
|
|
195
|
+
phases?: Record<string, number>;
|
|
196
|
+
files?: string[];
|
|
197
|
+
shadowed?: string[];
|
|
198
|
+
conflicts?: string[];
|
|
199
|
+
stage?: string;
|
|
200
|
+
url?: string;
|
|
201
|
+
reason?: string;
|
|
202
|
+
message?: string;
|
|
203
|
+
bundle?: string | null;
|
|
204
|
+
bundle_b64?: string;
|
|
205
|
+
bundle_bytes?: number;
|
|
206
|
+
hub?: boolean;
|
|
207
|
+
}
|
|
208
|
+
export type RoundOutcome = {
|
|
209
|
+
state: 'landed';
|
|
210
|
+
land: LandResult;
|
|
211
|
+
applied: Applied;
|
|
212
|
+
timings: Record<string, number>;
|
|
213
|
+
confirmed: Promise<void>;
|
|
214
|
+
} | {
|
|
215
|
+
state: 'noop' | 'pr' | 'conflict' | 'error';
|
|
216
|
+
land: LandResult;
|
|
217
|
+
timings: Record<string, number>;
|
|
218
|
+
} | {
|
|
219
|
+
state: 'paused' | 'moved' | 'busy';
|
|
220
|
+
reason: string;
|
|
221
|
+
waitMs?: number;
|
|
222
|
+
land?: LandResult;
|
|
223
|
+
timings: Record<string, number>;
|
|
224
|
+
};
|
|
225
|
+
/** The landing script, shipped next to this module (src in the monorepo, dist when published). */
|
|
226
|
+
export declare function landingScript(): string;
|
|
227
|
+
export declare function runLandingScript(cell: TwinCell, input: Record<string, unknown>, signal?: AbortSignal): Promise<LandResult>;
|
|
228
|
+
/** A temporary repository in the cell with the conversation's branch (and its earlier landing refs) from the hub. */
|
|
229
|
+
export declare const HUB_CHECKOUT = "set -eu\nd=$(mktemp -d \"${TMPDIR:-/tmp}/shard-land-XXXXXX\")\ngit init -q \"$d\"\ncd \"$d\"\ngit fetch -q --no-tags \"$1\" \"+refs/heads/$2:refs/heads/$2\"\ngit fetch -q --no-tags \"$1\" \"+refs/shard/land/$3/upto:refs/shard/land/upto\" 2>/dev/null || true\ngit symbolic-ref HEAD \"refs/heads/$2\"\nif [ -n \"$4\" ]; then git remote add origin \"$4\"; fi\nprintf '%s\\n' \"$d\"\n";
|
|
230
|
+
export declare function landingRound(cell: TwinCell, input: RoundInput): Promise<RoundOutcome>;
|
|
231
|
+
export type LandingMode = 'auto' | 'pr' | 'off';
|
|
232
|
+
export interface TwinConversationState {
|
|
233
|
+
base_branch?: string;
|
|
234
|
+
seed?: string;
|
|
235
|
+
seed_snapshot?: boolean;
|
|
236
|
+
folder?: string;
|
|
237
|
+
upto?: string;
|
|
238
|
+
tip?: string;
|
|
239
|
+
landed_revision?: string;
|
|
240
|
+
landed_at?: string;
|
|
241
|
+
method?: string;
|
|
242
|
+
conflict_tip?: string;
|
|
243
|
+
conflict_revision?: string;
|
|
244
|
+
pr_url?: string;
|
|
245
|
+
failures?: number;
|
|
246
|
+
retry_at?: number;
|
|
247
|
+
last_error?: string;
|
|
248
|
+
latency_ms?: number;
|
|
249
|
+
}
|
|
250
|
+
interface TwinDoc {
|
|
251
|
+
landing?: Record<string, LandingMode>;
|
|
252
|
+
conversations?: Record<string, TwinConversationState>;
|
|
253
|
+
}
|
|
254
|
+
export declare class TwinLedger {
|
|
255
|
+
readonly store: AgentStateStore;
|
|
256
|
+
constructor(dir: string);
|
|
257
|
+
read(): TwinDoc;
|
|
258
|
+
mode(project: string): LandingMode;
|
|
259
|
+
setMode(project: string, mode: LandingMode): void;
|
|
260
|
+
conversation(id: string): TwinConversationState;
|
|
261
|
+
patch(id: string, change: Partial<TwinConversationState>): void;
|
|
262
|
+
forget(id: string): void;
|
|
263
|
+
update(change: (doc: TwinDoc) => void): void;
|
|
264
|
+
}
|
|
265
|
+
export declare const backupRoot: (stateDir: string) => string;
|
|
266
|
+
export declare function relativeTo(root: string, path: string): string;
|
|
267
|
+
/** This computer's device id (the device helper's), created on first use. */
|
|
268
|
+
export declare function deviceId(stateDir: string): string;
|
|
269
|
+
export interface TwinSeed {
|
|
270
|
+
commit: string;
|
|
271
|
+
snapshot: boolean;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The commit a new conversation starts from: HEAD when the worktree is clean (or landing is off, or the user's Git
|
|
275
|
+
* operation is running), else the twin snapshot S (first parent HEAD). The conversation's own work is then S..T, and
|
|
276
|
+
* the user's edits stay theirs.
|
|
277
|
+
*/
|
|
278
|
+
export declare function twinSeed(stateDir: string, folder: string, head: string, project?: string): Promise<TwinSeed>;
|
|
279
|
+
export declare function rememberTwinSeed(stateDir: string, conversation: string, folder: string, seed: TwinSeed, branch?: string): void;
|
|
280
|
+
/** Carries a snapshot seed into the conversation's VM repository (one small bundle: the snapshot over HEAD). */
|
|
281
|
+
export declare function sendTwinSeed(stateDir: string, cell: TwinCell, conversation: {
|
|
282
|
+
id: string;
|
|
283
|
+
base_commit: string;
|
|
284
|
+
worktree_path: string;
|
|
285
|
+
}, folder: string, signal?: AbortSignal): Promise<void>;
|
|
286
|
+
/**
|
|
287
|
+
* The conversation branch at its base. A snapshot base already matches the copied files: the branch starts at HEAD
|
|
288
|
+
* and moves to the snapshot without touching them (`checkout` would refuse the "local changes").
|
|
289
|
+
*/
|
|
290
|
+
export declare function twinCheckoutArgv(stateDir: string, conversation: {
|
|
291
|
+
id: string;
|
|
292
|
+
branch: string;
|
|
293
|
+
base_commit: string;
|
|
294
|
+
worktree_path: string;
|
|
295
|
+
}): string[];
|
|
296
|
+
export {};
|