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.
Files changed (132) hide show
  1. package/BLAXEL.md +4 -4
  2. package/COMPATIBILITY.md +145 -0
  3. package/DAYTONA.md +13 -7
  4. package/E2B.md +34 -20
  5. package/README.md +22 -11
  6. package/README.src.md +22 -11
  7. package/VERCEL.md +13 -3
  8. package/compatibility-lock.json +357 -0
  9. package/dist/ai-harness/index.js +2 -2
  10. package/dist/api-defaults.d.ts +17 -0
  11. package/dist/api-defaults.js +22 -0
  12. package/dist/blaxel/errors.js +1 -1
  13. package/dist/blaxel/lookup.d.ts +3 -2
  14. package/dist/blaxel/lookup.js +3 -2
  15. package/dist/blaxel/pagination.d.ts +2 -2
  16. package/dist/blaxel/pagination.js +45 -17
  17. package/dist/blaxel/preview.d.ts +3 -2
  18. package/dist/blaxel/preview.js +16 -14
  19. package/dist/blaxel/process.d.ts +1 -1
  20. package/dist/blaxel/process.js +66 -11
  21. package/dist/blaxel/sandbox.d.ts +2 -0
  22. package/dist/blaxel/sandbox.js +2 -0
  23. package/dist/blaxel/snapshot.js +3 -5
  24. package/dist/cli-words.d.ts +27 -0
  25. package/dist/cli-words.js +135 -0
  26. package/dist/cli.d.ts +2 -0
  27. package/dist/cli.js +351 -85
  28. package/dist/client.d.ts +33 -4
  29. package/dist/client.js +47 -12
  30. package/dist/cloudflare/identity.d.ts +7 -0
  31. package/dist/cloudflare/identity.js +13 -0
  32. package/dist/cloudflare/index.d.ts +303 -0
  33. package/dist/cloudflare/index.js +592 -0
  34. package/dist/cloudflare/worker.d.ts +419 -0
  35. package/dist/cloudflare/worker.js +447 -0
  36. package/dist/codesandbox/guest-service.d.ts +31 -0
  37. package/dist/codesandbox/guest-service.js +490 -0
  38. package/dist/codesandbox/index.d.ts +386 -0
  39. package/dist/codesandbox/index.js +1347 -0
  40. package/dist/compat/core.d.ts +40 -0
  41. package/dist/compat/core.js +238 -0
  42. package/dist/compat/files.d.ts +26 -0
  43. package/dist/compat/files.js +133 -0
  44. package/dist/compat/shell.d.ts +4 -0
  45. package/dist/compat/shell.js +165 -0
  46. package/dist/credentials.d.ts +6 -1
  47. package/dist/credentials.js +107 -38
  48. package/dist/daytona/context.js +2 -1
  49. package/dist/daytona/daytona.js +11 -4
  50. package/dist/daytona/filesystem.d.ts +17 -4
  51. package/dist/daytona/filesystem.js +121 -38
  52. package/dist/daytona/index.d.ts +1 -1
  53. package/dist/daytona/process.d.ts +1 -1
  54. package/dist/daytona/process.js +27 -4
  55. package/dist/daytona/sandbox.d.ts +1 -1
  56. package/dist/daytona/sandbox.js +16 -7
  57. package/dist/e2b/client.js +1 -1
  58. package/dist/e2b/code-interpreter.d.ts +4 -5
  59. package/dist/e2b/code-interpreter.js +54 -56
  60. package/dist/e2b/commands.d.ts +35 -12
  61. package/dist/e2b/commands.js +169 -73
  62. package/dist/e2b/errors.js +1 -1
  63. package/dist/e2b/filesystem.d.ts +8 -6
  64. package/dist/e2b/filesystem.js +127 -28
  65. package/dist/e2b/index.d.ts +1 -0
  66. package/dist/e2b/index.js +1 -0
  67. package/dist/e2b/pty.d.ts +24 -0
  68. package/dist/e2b/pty.js +80 -0
  69. package/dist/e2b/sandbox.d.ts +6 -4
  70. package/dist/e2b/sandbox.js +59 -25
  71. package/dist/errors.d.ts +8 -0
  72. package/dist/errors.js +15 -2
  73. package/dist/file-source.d.ts +16 -0
  74. package/dist/file-source.js +162 -0
  75. package/dist/freestyle/index.d.ts +309 -0
  76. package/dist/freestyle/index.js +343 -0
  77. package/dist/index.d.ts +3 -1
  78. package/dist/login.js +16 -10
  79. package/dist/mcp.js +2 -2
  80. package/dist/modal/index.d.ts +205 -0
  81. package/dist/modal/index.js +486 -0
  82. package/dist/network-cli.js +112 -44
  83. package/dist/openai-agents/index.js +18 -71
  84. package/dist/page.d.ts +1 -1
  85. package/dist/page.js +2 -2
  86. package/dist/products/audit.d.ts +3 -0
  87. package/dist/products/index.d.ts +14 -0
  88. package/dist/products/index.js +12 -0
  89. package/dist/products/interpreter.d.ts +11 -4
  90. package/dist/products/interpreter.js +33 -9
  91. package/dist/products/jobs.js +3 -2
  92. package/dist/products/limits.d.ts +10 -0
  93. package/dist/products/observability.d.ts +44 -0
  94. package/dist/products/observability.js +6 -0
  95. package/dist/products/previews.d.ts +18 -4
  96. package/dist/products/previews.js +14 -5
  97. package/dist/products/tailscale.d.ts +57 -0
  98. package/dist/products/tailscale.js +17 -0
  99. package/dist/products/volumes.d.ts +42 -0
  100. package/dist/products/volumes.js +36 -0
  101. package/dist/products/watch.d.ts +25 -4
  102. package/dist/products/watch.js +132 -18
  103. package/dist/proxy.js +3 -1
  104. package/dist/runloop/index.d.ts +326 -0
  105. package/dist/runloop/index.js +535 -0
  106. package/dist/runloop/pagination.d.ts +46 -0
  107. package/dist/runloop/pagination.js +82 -0
  108. package/dist/runloop/snapshots.d.ts +52 -0
  109. package/dist/runloop/snapshots.js +244 -0
  110. package/dist/sandbox.d.ts +76 -16
  111. package/dist/sandbox.js +696 -135
  112. package/dist/snapshots.d.ts +20 -5
  113. package/dist/snapshots.js +11 -1
  114. package/dist/sprites/index.d.ts +251 -0
  115. package/dist/sprites/index.js +588 -0
  116. package/dist/tar.d.ts +10 -1
  117. package/dist/tar.js +311 -35
  118. package/dist/tools/index.js +2 -1
  119. package/dist/transport.d.ts +23 -8
  120. package/dist/transport.js +247 -127
  121. package/dist/types.d.ts +52 -3
  122. package/dist/vercel/command.d.ts +1 -1
  123. package/dist/vercel/command.js +15 -3
  124. package/dist/vercel/errors.d.ts +2 -2
  125. package/dist/vercel/errors.js +19 -2
  126. package/dist/vercel/filesystem.d.ts +21 -18
  127. package/dist/vercel/filesystem.js +95 -48
  128. package/dist/vercel/sandbox.d.ts +8 -8
  129. package/dist/vercel/sandbox.js +159 -64
  130. package/dist/vercel/snapshot.d.ts +3 -2
  131. package/dist/vercel/snapshot.js +9 -5
  132. 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
+ }