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