withruntime 0.8.4 → 0.9.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/COMPATIBILITY.md +145 -0
- package/E2B.md +23 -13
- package/README.md +8 -5
- package/README.src.md +8 -5
- package/compatibility-lock.json +281 -0
- package/dist/ai-harness/index.js +2 -2
- package/dist/api-defaults.d.ts +17 -0
- package/dist/api-defaults.js +22 -0
- package/dist/blaxel/preview.d.ts +1 -1
- package/dist/blaxel/preview.js +1 -1
- package/dist/blaxel/process.d.ts +1 -1
- package/dist/blaxel/process.js +11 -2
- package/dist/cli-words.d.ts +27 -0
- package/dist/cli-words.js +135 -0
- package/dist/cli.js +162 -31
- package/dist/client.d.ts +4 -1
- package/dist/client.js +8 -2
- package/dist/cloudflare/index.d.ts +294 -0
- package/dist/cloudflare/index.js +554 -0
- package/dist/cloudflare/worker.d.ts +415 -0
- package/dist/cloudflare/worker.js +297 -0
- package/dist/codesandbox/guest-service.d.ts +31 -0
- package/dist/codesandbox/guest-service.js +453 -0
- package/dist/codesandbox/index.d.ts +386 -0
- package/dist/codesandbox/index.js +1313 -0
- package/dist/compat/core.d.ts +39 -0
- package/dist/compat/core.js +199 -0
- package/dist/compat/files.d.ts +26 -0
- package/dist/compat/files.js +115 -0
- package/dist/compat/shell.d.ts +4 -0
- package/dist/compat/shell.js +165 -0
- package/dist/daytona/context.js +2 -1
- package/dist/daytona/process.d.ts +1 -1
- package/dist/daytona/process.js +27 -4
- package/dist/e2b/code-interpreter.d.ts +4 -5
- package/dist/e2b/code-interpreter.js +54 -56
- package/dist/e2b/commands.d.ts +5 -4
- package/dist/e2b/commands.js +37 -29
- package/dist/e2b/errors.js +1 -1
- package/dist/e2b/filesystem.d.ts +8 -6
- package/dist/e2b/filesystem.js +127 -28
- package/dist/e2b/sandbox.d.ts +4 -3
- package/dist/e2b/sandbox.js +10 -10
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +15 -2
- package/dist/freestyle/index.d.ts +301 -0
- package/dist/freestyle/index.js +310 -0
- package/dist/index.d.ts +1 -0
- package/dist/mcp.js +2 -2
- package/dist/modal/index.d.ts +205 -0
- package/dist/modal/index.js +485 -0
- package/dist/network-cli.js +1 -1
- package/dist/openai-agents/index.js +9 -26
- package/dist/products/audit.d.ts +3 -0
- package/dist/products/index.d.ts +2 -0
- package/dist/products/index.js +2 -0
- package/dist/products/interpreter.d.ts +11 -4
- package/dist/products/interpreter.js +33 -9
- package/dist/products/jobs.js +3 -2
- package/dist/products/limits.d.ts +10 -0
- package/dist/products/previews.d.ts +10 -2
- package/dist/products/previews.js +3 -2
- package/dist/products/tailscale.d.ts +57 -0
- package/dist/products/tailscale.js +17 -0
- package/dist/products/watch.d.ts +25 -4
- package/dist/products/watch.js +75 -14
- package/dist/runloop/index.d.ts +340 -0
- package/dist/runloop/index.js +438 -0
- package/dist/runloop/pagination.d.ts +46 -0
- package/dist/runloop/pagination.js +82 -0
- package/dist/runloop/snapshots.d.ts +52 -0
- package/dist/runloop/snapshots.js +244 -0
- package/dist/sandbox.d.ts +40 -9
- package/dist/sandbox.js +273 -76
- package/dist/snapshots.d.ts +11 -1
- package/dist/snapshots.js +11 -1
- package/dist/sprites/index.d.ts +251 -0
- package/dist/sprites/index.js +552 -0
- package/dist/tar.d.ts +8 -1
- package/dist/tar.js +185 -35
- package/dist/tools/index.js +2 -1
- package/dist/transport.d.ts +20 -2
- package/dist/transport.js +48 -11
- package/dist/types.d.ts +52 -3
- package/dist/vercel/command.js +12 -1
- package/dist/vercel/errors.d.ts +2 -2
- package/dist/vercel/errors.js +12 -1
- package/dist/vercel/filesystem.d.ts +21 -18
- package/dist/vercel/filesystem.js +95 -48
- package/dist/vercel/sandbox.js +5 -5
- package/package.json +45 -3
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
const root = "/workspace/.runtime-compat/codesandbox/service";
|
|
2
|
+
export const taskServiceSource = String.raw `import base64,errno,fcntl,json,os,pty,select,signal,socket,socketserver,struct,subprocess,sys,termios,threading,time,uuid
|
|
3
|
+
root=sys.argv[1]
|
|
4
|
+
os.makedirs(root,mode=0o700,exist_ok=True)
|
|
5
|
+
os.umask(0o077)
|
|
6
|
+
lock=open(os.path.join(root,'lock'),'a')
|
|
7
|
+
try: fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
|
|
8
|
+
except BlockingIOError: sys.exit(0)
|
|
9
|
+
mutex=threading.RLock()
|
|
10
|
+
runs={}
|
|
11
|
+
current={}
|
|
12
|
+
boot_state={'state':'starting'}
|
|
13
|
+
def atomic(path,data):
|
|
14
|
+
temp=path+'.tmp'
|
|
15
|
+
with open(temp,'w') as f: json.dump(data,f)
|
|
16
|
+
os.replace(temp,path)
|
|
17
|
+
def identity(pid):
|
|
18
|
+
try:
|
|
19
|
+
with open('/proc/'+str(pid)+'/stat') as f: return f.read().rsplit(')',1)[1].split()[19]
|
|
20
|
+
except FileNotFoundError: return None
|
|
21
|
+
def record(run):
|
|
22
|
+
with run['_lock']: return {k:v for k,v in run.items() if k not in ('process','fd','writer','_lock')}
|
|
23
|
+
def save(run):
|
|
24
|
+
value=record(run)
|
|
25
|
+
value.pop('inputWriters',None)
|
|
26
|
+
atomic(os.path.join(root,run['id']+'.json'),value)
|
|
27
|
+
def settled(run):
|
|
28
|
+
try: save(run)
|
|
29
|
+
except OSError as e:
|
|
30
|
+
# Keep terminal failure readable over the socket even when storage is full.
|
|
31
|
+
run['exitCode']=125
|
|
32
|
+
run['error']=(run.get('error','')+'; ' if run.get('error') else '')+'Cannot persist task result: '+str(e)
|
|
33
|
+
def complete(run):
|
|
34
|
+
fd=run['fd']
|
|
35
|
+
failed=False
|
|
36
|
+
try:
|
|
37
|
+
with open(os.path.join(root,run['id']+'.log'),'ab',buffering=0) as out:
|
|
38
|
+
while True:
|
|
39
|
+
ready,_,_=select.select([fd],[],[],0.05)
|
|
40
|
+
if not ready:
|
|
41
|
+
if run['process'].poll() is not None: break
|
|
42
|
+
continue
|
|
43
|
+
try: data=os.read(fd,65536)
|
|
44
|
+
except OSError as e:
|
|
45
|
+
if e.errno==errno.EIO: break
|
|
46
|
+
raise
|
|
47
|
+
if not data: break
|
|
48
|
+
out.write(data)
|
|
49
|
+
except BaseException as e:
|
|
50
|
+
failed=True
|
|
51
|
+
with run['_lock']: run['error']=str(e)
|
|
52
|
+
try: os.killpg(run['process'].pid,signal.SIGKILL)
|
|
53
|
+
except ProcessLookupError: pass
|
|
54
|
+
finally:
|
|
55
|
+
code=run['process'].wait()
|
|
56
|
+
with run['_lock']:
|
|
57
|
+
try: os.close(fd)
|
|
58
|
+
except OSError: pass
|
|
59
|
+
run['fd']=None
|
|
60
|
+
run['exitCode']=125 if failed else code
|
|
61
|
+
run['state']='exited'
|
|
62
|
+
settled(run)
|
|
63
|
+
def stop(run):
|
|
64
|
+
if run.get('state')!='running': return
|
|
65
|
+
proc=run.get('process')
|
|
66
|
+
if proc is not None and proc.poll() is None:
|
|
67
|
+
try: os.killpg(proc.pid,signal.SIGTERM)
|
|
68
|
+
except ProcessLookupError: pass
|
|
69
|
+
try: proc.wait(timeout=2)
|
|
70
|
+
except subprocess.TimeoutExpired:
|
|
71
|
+
try: os.killpg(proc.pid,signal.SIGKILL)
|
|
72
|
+
except ProcessLookupError: pass
|
|
73
|
+
proc.wait()
|
|
74
|
+
elif proc is None and run.get('pid') and run.get('identity') and identity(run['pid'])==run['identity']:
|
|
75
|
+
# On supervisor recovery, fence against PID reuse before touching a process.
|
|
76
|
+
try: os.killpg(run['pid'],signal.SIGKILL)
|
|
77
|
+
except ProcessLookupError: pass
|
|
78
|
+
if proc is not None and run.get('writer'):
|
|
79
|
+
run['writer'].join(timeout=3)
|
|
80
|
+
if run['writer'].is_alive(): raise TimeoutError('Task output did not finish after stop')
|
|
81
|
+
if proc is None:
|
|
82
|
+
run['state']='exited'
|
|
83
|
+
run['exitCode']=-signal.SIGKILL
|
|
84
|
+
settled(run)
|
|
85
|
+
def config():
|
|
86
|
+
with open(os.path.join(root,'config.json')) as f: return json.load(f)
|
|
87
|
+
def start(task):
|
|
88
|
+
cfg=config()
|
|
89
|
+
definition=cfg['tasks'].get(task)
|
|
90
|
+
if definition is None: raise ValueError('No such task')
|
|
91
|
+
old=current.get(task)
|
|
92
|
+
if old is not None: stop(runs[old])
|
|
93
|
+
master,slave=pty.openpty()
|
|
94
|
+
fcntl.ioctl(slave,termios.TIOCSWINSZ,struct.pack('HHHH',24,128,0,0))
|
|
95
|
+
try:
|
|
96
|
+
proc=subprocess.Popen(['bash','-c',definition['command']],cwd=cfg['cwd'],env=dict(os.environ,**cfg.get('env',{})),stdin=slave,stdout=slave,stderr=slave,start_new_session=True)
|
|
97
|
+
except BaseException:
|
|
98
|
+
os.close(master)
|
|
99
|
+
raise
|
|
100
|
+
finally: os.close(slave)
|
|
101
|
+
run={'id':str(uuid.uuid4()),'taskId':task,'pid':proc.pid,'identity':identity(proc.pid),'state':'running','exitCode':None,'command':definition['command'],'startedAt':time.time()*1000,'process':proc,'fd':master,'_lock':threading.RLock()}
|
|
102
|
+
runs[run['id']]=run
|
|
103
|
+
current[task]=run['id']
|
|
104
|
+
try:
|
|
105
|
+
save(run)
|
|
106
|
+
atomic(os.path.join(root,'current.json'),current)
|
|
107
|
+
except BaseException:
|
|
108
|
+
try: os.killpg(proc.pid,signal.SIGKILL)
|
|
109
|
+
except ProcessLookupError: pass
|
|
110
|
+
proc.wait()
|
|
111
|
+
os.close(master)
|
|
112
|
+
runs.pop(run['id'],None)
|
|
113
|
+
if old is None: current.pop(task,None)
|
|
114
|
+
else: current[task]=old
|
|
115
|
+
raise
|
|
116
|
+
run['writer']=threading.Thread(target=complete,args=(run,),daemon=True)
|
|
117
|
+
run['writer'].start()
|
|
118
|
+
return record(run)
|
|
119
|
+
def write(req):
|
|
120
|
+
data=base64.b64decode(req['data'],validate=True)
|
|
121
|
+
with mutex:
|
|
122
|
+
run=runs.get(req['id'])
|
|
123
|
+
if run is None: raise ValueError('Task is not running')
|
|
124
|
+
# Own this open file description across close/restart; never reuse a raw FD.
|
|
125
|
+
with run['_lock']:
|
|
126
|
+
if run['state']!='running': raise ValueError('Task is not running')
|
|
127
|
+
fd=os.dup(run['fd'])
|
|
128
|
+
run['inputWriters']=run.get('inputWriters',0)+1
|
|
129
|
+
try:
|
|
130
|
+
os.set_blocking(fd,False)
|
|
131
|
+
deadline=time.monotonic()+5
|
|
132
|
+
while data:
|
|
133
|
+
with mutex:
|
|
134
|
+
if run['state']!='running': raise ValueError('Task is not running')
|
|
135
|
+
remaining=deadline-time.monotonic()
|
|
136
|
+
if remaining<=0: raise TimeoutError('Task input write timed out')
|
|
137
|
+
_,ready,_=select.select([],[fd],[],min(remaining,0.1))
|
|
138
|
+
if not ready: continue
|
|
139
|
+
try: sent=os.write(fd,data)
|
|
140
|
+
except BlockingIOError: continue
|
|
141
|
+
data=data[sent:]
|
|
142
|
+
finally:
|
|
143
|
+
os.close(fd)
|
|
144
|
+
with run['_lock']: run['inputWriters']-=1
|
|
145
|
+
def read(req):
|
|
146
|
+
with mutex:
|
|
147
|
+
run=runs.get(req['id'])
|
|
148
|
+
if run is None: raise ValueError('No such task run')
|
|
149
|
+
offset=req.get('offset',0)
|
|
150
|
+
deadline=time.monotonic()+min(max(req.get('waitMs',0),0),2000)/1000
|
|
151
|
+
while True:
|
|
152
|
+
# Observe terminal status before reading: exited guarantees logs are drained.
|
|
153
|
+
snapshot=record(run)
|
|
154
|
+
try:
|
|
155
|
+
with open(os.path.join(root,run['id']+'.log'),'rb') as f:
|
|
156
|
+
size=os.fstat(f.fileno()).st_size
|
|
157
|
+
f.seek(offset); data=f.read(min(65536,max(0,req.get('until',size)-offset)))
|
|
158
|
+
except FileNotFoundError: data=b''; size=0
|
|
159
|
+
if data or snapshot['state']!='running' or time.monotonic()>=deadline:
|
|
160
|
+
return dict(snapshot,data=base64.b64encode(data).decode(),offset=offset+len(data),size=size)
|
|
161
|
+
time.sleep(0.02)
|
|
162
|
+
def handle(req):
|
|
163
|
+
if req['op']=='write': return write(req)
|
|
164
|
+
if req['op']=='read': return read(req)
|
|
165
|
+
with mutex:
|
|
166
|
+
op=req['op']
|
|
167
|
+
if op=='ping': return {'ok':True}
|
|
168
|
+
if op=='boot': return dict(boot_state)
|
|
169
|
+
if op=='list': return [record(runs[id]) for id in current.values() if id in runs]
|
|
170
|
+
if op=='get':
|
|
171
|
+
id=current.get(req['taskId'])
|
|
172
|
+
return record(runs[id]) if id is not None else None
|
|
173
|
+
if op=='start': return start(req['taskId'])
|
|
174
|
+
if op=='stopTask':
|
|
175
|
+
id=current.get(req['taskId'])
|
|
176
|
+
if id is None: return None
|
|
177
|
+
stop(runs[id]); return record(runs[id])
|
|
178
|
+
run=runs.get(req['id'])
|
|
179
|
+
if run is None: raise ValueError('No such task run')
|
|
180
|
+
if op=='stop': stop(run); return record(run)
|
|
181
|
+
if op=='resize':
|
|
182
|
+
with run['_lock']:
|
|
183
|
+
if run['state']=='running': fcntl.ioctl(run['fd'],termios.TIOCSWINSZ,struct.pack('HHHH',req['rows'],req['cols'],0,0))
|
|
184
|
+
return None
|
|
185
|
+
raise ValueError('Unknown operation')
|
|
186
|
+
# A restarted service never assumes a recorded PID still belongs to it.
|
|
187
|
+
try:
|
|
188
|
+
with open(os.path.join(root,'current.json')) as f: current=json.load(f)
|
|
189
|
+
except FileNotFoundError: pass
|
|
190
|
+
for id in list(current.values()):
|
|
191
|
+
try:
|
|
192
|
+
with open(os.path.join(root,id+'.json')) as f: run=json.load(f)
|
|
193
|
+
run['_lock']=threading.RLock()
|
|
194
|
+
stop(run)
|
|
195
|
+
runs[id]=run
|
|
196
|
+
except FileNotFoundError: pass
|
|
197
|
+
class Handler(socketserver.StreamRequestHandler):
|
|
198
|
+
def handle(self):
|
|
199
|
+
try:
|
|
200
|
+
line=self.rfile.readline(1048577)
|
|
201
|
+
if len(line)>1048576: raise ValueError('Request too large')
|
|
202
|
+
result={'ok':True,'value':handle(json.loads(line))}
|
|
203
|
+
except Exception as e: result={'ok':False,'error':str(e)}
|
|
204
|
+
try: self.wfile.write(json.dumps(result).encode()+b'\n')
|
|
205
|
+
except (BrokenPipeError,ConnectionResetError): pass # The SDK detached its request.
|
|
206
|
+
class Server(socketserver.ThreadingUnixStreamServer): daemon_threads=True
|
|
207
|
+
path=os.path.join(root,'socket')
|
|
208
|
+
boot_state['config']=config().get('setup',{}).get('config')
|
|
209
|
+
try: os.unlink(path)
|
|
210
|
+
except FileNotFoundError: pass
|
|
211
|
+
server=Server(path,Handler)
|
|
212
|
+
def shutdown(signum,frame):
|
|
213
|
+
with mutex:
|
|
214
|
+
for run in runs.values(): stop(run)
|
|
215
|
+
os._exit(128+signum)
|
|
216
|
+
signal.signal(signal.SIGTERM,shutdown)
|
|
217
|
+
signal.signal(signal.SIGINT,shutdown)
|
|
218
|
+
def boot():
|
|
219
|
+
cfg=config()
|
|
220
|
+
setup=cfg.get('setup')
|
|
221
|
+
with mutex: boot_state['config']=setup.get('config') if setup else None
|
|
222
|
+
if setup and setup['steps']:
|
|
223
|
+
setup_root=os.path.join(os.path.dirname(root),'setup')
|
|
224
|
+
os.makedirs(setup_root,mode=0o700,exist_ok=True)
|
|
225
|
+
progress=os.path.join(setup_root,'progress.json')
|
|
226
|
+
try:
|
|
227
|
+
with open(progress) as f: previous=json.load(f)
|
|
228
|
+
except FileNotFoundError: previous={}
|
|
229
|
+
if previous.get('config')!=setup['config'] or previous.get('state')!='FINISHED':
|
|
230
|
+
run=dict(setup,runId=str(uuid.uuid4()),cwd=cfg['cwd'])
|
|
231
|
+
path=os.path.join(setup_root,run['runId']+'.json')
|
|
232
|
+
atomic(path,run)
|
|
233
|
+
result=subprocess.run([sys.executable,os.path.join(root,'setup.py'),path],env=dict(os.environ,**cfg.get('env',{})),capture_output=True,text=True)
|
|
234
|
+
if result.returncode!=0: raise RuntimeError(result.stderr.strip() or 'Setup exited with code '+str(result.returncode))
|
|
235
|
+
while True:
|
|
236
|
+
try:
|
|
237
|
+
with open(progress) as f: result=json.load(f)
|
|
238
|
+
except FileNotFoundError: return
|
|
239
|
+
if result.get('state')!='IN_PROGRESS': break
|
|
240
|
+
time.sleep(0.1)
|
|
241
|
+
if result.get('state')!='FINISHED' or result.get('config')!=setup['config']: return
|
|
242
|
+
# The service owns automatic tasks independently of SDK connections.
|
|
243
|
+
with mutex:
|
|
244
|
+
for task,definition in cfg['tasks'].items():
|
|
245
|
+
if definition.get('runAtStart'):
|
|
246
|
+
try: start(task)
|
|
247
|
+
except Exception as e:
|
|
248
|
+
# A failed automatic launch must remain observable even if storage is full.
|
|
249
|
+
run={'id':str(uuid.uuid4()),'taskId':task,'state':'exited','exitCode':125,'command':definition['command'],'startedAt':time.time()*1000,'error':'Automatic task startup failed: '+str(e),'_lock':threading.RLock()}
|
|
250
|
+
runs[run['id']]=run
|
|
251
|
+
current[task]=run['id']
|
|
252
|
+
settled(run)
|
|
253
|
+
try: atomic(os.path.join(root,'current.json'),current)
|
|
254
|
+
except OSError: pass
|
|
255
|
+
def boot_guard():
|
|
256
|
+
try:
|
|
257
|
+
boot()
|
|
258
|
+
with mutex: boot_state['state']='ready'
|
|
259
|
+
except Exception as e:
|
|
260
|
+
with mutex: boot_state.update(state='failed',error='Task setup startup failed: '+str(e))
|
|
261
|
+
threading.Thread(target=boot_guard,daemon=True).start()
|
|
262
|
+
server.serve_forever()
|
|
263
|
+
`;
|
|
264
|
+
const requestSource = String.raw `import json,socket,sys
|
|
265
|
+
s=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
|
|
266
|
+
s.settimeout(10)
|
|
267
|
+
s.connect(sys.argv[1]+'/socket')
|
|
268
|
+
s.sendall(sys.stdin.buffer.read()+b'\n')
|
|
269
|
+
f=s.makefile('rb')
|
|
270
|
+
try: print(f.readline(1048576).decode(),end='')
|
|
271
|
+
finally: f.close(); s.close()
|
|
272
|
+
`;
|
|
273
|
+
const installSource = String.raw `import os,subprocess,sys
|
|
274
|
+
root=sys.argv[1]
|
|
275
|
+
unit='''[Unit]
|
|
276
|
+
Description=Runtime CodeSandbox task supervisor
|
|
277
|
+
After=runtime-guest-agent.service
|
|
278
|
+
[Service]
|
|
279
|
+
Type=simple
|
|
280
|
+
User=1000
|
|
281
|
+
Group=1000
|
|
282
|
+
WorkingDirectory=/project/sandbox
|
|
283
|
+
ExecStart=/usr/bin/python3 ROOT/service.py ROOT
|
|
284
|
+
Restart=on-failure
|
|
285
|
+
RestartSec=1
|
|
286
|
+
KillMode=control-group
|
|
287
|
+
TimeoutStopSec=5
|
|
288
|
+
UMask=0077
|
|
289
|
+
[Install]
|
|
290
|
+
WantedBy=multi-user.target
|
|
291
|
+
'''.replace('ROOT',root)
|
|
292
|
+
path='/etc/systemd/system/runtime-codesandbox-tasks.service'
|
|
293
|
+
if not os.path.exists(path) or open(path).read()!=unit:
|
|
294
|
+
temp=path+'.'+str(os.getpid())+'.tmp'
|
|
295
|
+
with open(temp,'w') as f: f.write(unit)
|
|
296
|
+
os.chmod(temp,0o644)
|
|
297
|
+
os.replace(temp,path)
|
|
298
|
+
subprocess.run(['systemctl','daemon-reload'],check=True)
|
|
299
|
+
subprocess.run(['systemctl','enable','--now','runtime-codesandbox-tasks.service'],check=True)
|
|
300
|
+
`;
|
|
301
|
+
export class ManagedTasks {
|
|
302
|
+
native;
|
|
303
|
+
constructor(native) {
|
|
304
|
+
this.native = native;
|
|
305
|
+
}
|
|
306
|
+
async install(tasks, env, setup) {
|
|
307
|
+
const revision = crypto.randomUUID();
|
|
308
|
+
const script = `${root}/service-${revision}.py`, setupScript = `${root}/setup-${revision}.py`;
|
|
309
|
+
await this.native.files.write(script, taskServiceSource, { mode: 0o600 });
|
|
310
|
+
await this.native.files.write(setupScript, setup.source, { mode: 0o600 });
|
|
311
|
+
const pending = `${root}/config-${revision}.json`;
|
|
312
|
+
await this.native.files.write(pending, JSON.stringify({
|
|
313
|
+
tasks,
|
|
314
|
+
env,
|
|
315
|
+
cwd: "/project/sandbox",
|
|
316
|
+
setup: { steps: setup.steps, config: JSON.stringify(setup.steps) },
|
|
317
|
+
}), { mode: 0o600 });
|
|
318
|
+
await this.native.exec([
|
|
319
|
+
"python3",
|
|
320
|
+
"-c",
|
|
321
|
+
"import os,sys\nfor i in range(1,len(sys.argv),2): os.replace(sys.argv[i],sys.argv[i+1])",
|
|
322
|
+
script,
|
|
323
|
+
`${root}/service.py`,
|
|
324
|
+
setupScript,
|
|
325
|
+
`${root}/setup.py`,
|
|
326
|
+
pending,
|
|
327
|
+
`${root}/config.json`,
|
|
328
|
+
], { check: true });
|
|
329
|
+
await this.native.exec(["sudo", "-n", "python3", "-c", installSource, root], { check: true });
|
|
330
|
+
const until = Date.now() + 5000;
|
|
331
|
+
for (;;) {
|
|
332
|
+
try {
|
|
333
|
+
await this.call({ op: "ping" });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
if (Date.now() >= until)
|
|
338
|
+
throw error;
|
|
339
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
async call(request, signal) {
|
|
344
|
+
const result = await this.native.exec(["python3", "-c", requestSource, root], {
|
|
345
|
+
check: true,
|
|
346
|
+
signal,
|
|
347
|
+
stdin: JSON.stringify(request),
|
|
348
|
+
onStdout: () => { },
|
|
349
|
+
});
|
|
350
|
+
const reply = JSON.parse(result.stdout);
|
|
351
|
+
if (!reply.ok)
|
|
352
|
+
throw new Error(reply.error ?? "Task supervisor failed");
|
|
353
|
+
return reply.value;
|
|
354
|
+
}
|
|
355
|
+
async get(taskId) {
|
|
356
|
+
const run = await this.call({ op: "get", taskId });
|
|
357
|
+
return run ? this.process(run) : undefined;
|
|
358
|
+
}
|
|
359
|
+
async boot(signal) {
|
|
360
|
+
if (!(await this.native.files.exists(`${root}/config.json`, { signal })))
|
|
361
|
+
return undefined;
|
|
362
|
+
return this.call({ op: "boot" }, signal);
|
|
363
|
+
}
|
|
364
|
+
async start(taskId) {
|
|
365
|
+
return this.process(await this.call({ op: "start", taskId }));
|
|
366
|
+
}
|
|
367
|
+
async stop(taskId) {
|
|
368
|
+
const run = await this.call({ op: "stopTask", taskId });
|
|
369
|
+
return run ? this.process(run) : undefined;
|
|
370
|
+
}
|
|
371
|
+
async list(signal) {
|
|
372
|
+
return (await this.call({ op: "list" }, signal)).map((run) => ({
|
|
373
|
+
taskId: run.taskId,
|
|
374
|
+
process: this.process(run),
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
async output(id) {
|
|
378
|
+
const chunks = [];
|
|
379
|
+
let offset = 0, until;
|
|
380
|
+
do {
|
|
381
|
+
const result = await this.call({
|
|
382
|
+
op: "read",
|
|
383
|
+
id,
|
|
384
|
+
offset,
|
|
385
|
+
until,
|
|
386
|
+
});
|
|
387
|
+
until ??= result.size;
|
|
388
|
+
chunks.push(Buffer.from(result.data, "base64"));
|
|
389
|
+
offset = result.offset;
|
|
390
|
+
if (!result.data)
|
|
391
|
+
break;
|
|
392
|
+
} while (offset < until);
|
|
393
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
394
|
+
}
|
|
395
|
+
process(run) {
|
|
396
|
+
const call = (request, signal) => this.call(request, signal);
|
|
397
|
+
const handle = {
|
|
398
|
+
id: run.id,
|
|
399
|
+
info: run,
|
|
400
|
+
async *output({ signal } = {}) {
|
|
401
|
+
let offset = 0;
|
|
402
|
+
const decoder = new TextDecoder();
|
|
403
|
+
for (;;) {
|
|
404
|
+
signal?.throwIfAborted();
|
|
405
|
+
const result = await call({ op: "read", id: run.id, offset, waitMs: 2000 }, signal);
|
|
406
|
+
Object.assign(run, result);
|
|
407
|
+
if (result.data)
|
|
408
|
+
yield {
|
|
409
|
+
type: "stdout",
|
|
410
|
+
data: decoder.decode(Buffer.from(result.data, "base64"), { stream: true }),
|
|
411
|
+
offset,
|
|
412
|
+
};
|
|
413
|
+
offset = result.offset;
|
|
414
|
+
if (result.state !== "running" && !result.data) {
|
|
415
|
+
const tail = decoder.decode();
|
|
416
|
+
if (tail)
|
|
417
|
+
yield { type: "stdout", data: tail, offset };
|
|
418
|
+
if (result.error)
|
|
419
|
+
throw new Error(result.error);
|
|
420
|
+
yield { type: "exit", exitCode: result.exitCode, state: "exited", timedOut: false };
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (!result.data)
|
|
424
|
+
await new Promise((resolve, reject) => {
|
|
425
|
+
const abort = () => {
|
|
426
|
+
clearTimeout(timer);
|
|
427
|
+
reject(signal?.reason instanceof Error
|
|
428
|
+
? signal.reason
|
|
429
|
+
: new Error("Task subscription cancelled"));
|
|
430
|
+
};
|
|
431
|
+
const timer = setTimeout(() => {
|
|
432
|
+
signal?.removeEventListener("abort", abort);
|
|
433
|
+
resolve();
|
|
434
|
+
}, 100);
|
|
435
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
436
|
+
if (signal?.aborted)
|
|
437
|
+
abort();
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
async kill() {
|
|
442
|
+
Object.assign(run, await call({ op: "stop", id: run.id }));
|
|
443
|
+
},
|
|
444
|
+
async resize(cols, rows) {
|
|
445
|
+
await call({ op: "resize", id: run.id, cols, rows });
|
|
446
|
+
},
|
|
447
|
+
async write(data) {
|
|
448
|
+
await call({ op: "write", id: run.id, data: Buffer.from(data).toString("base64") });
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
return handle;
|
|
452
|
+
}
|
|
453
|
+
}
|