diff --git a/deploy/test/remote_e2e.py b/deploy/test/remote_e2e.py new file mode 100644 index 0000000..43ff88e --- /dev/null +++ b/deploy/test/remote_e2e.py @@ -0,0 +1,352 @@ +# End-to-end test of a brain on a server working on a person's computer, all on this machine and +# all made up for the test: an account server, a headless brain, a headless "hands" computer (it +# also plays the person's screen, as a Mac showing the brain does), and a scripted fake model. +# Never touches a real account, and no Docker box (the bot's own computer is a sealed folder). +# Build first (cargo build --release; and in relay/), then, from app/: +# python3 -I deploy/test/remote_e2e.py . +# On a Mac the terminal needs 輔助使用 (the hands open Calculator through agent-desktop). +import json, os, shutil, socket, subprocess, sys, tempfile, threading, time, urllib.request, urllib.error +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +APP = os.path.abspath(sys.argv[1]) +BIN = os.path.join(APP, 'target/release/anybot') +RELAY = os.path.join(APP, 'relay/target/release/anybot-relay') +WORK = tempfile.mkdtemp(prefix='anybot-e2e-') +USER, PW = 'e2euser', 'e2e-pass-12345' +HANDS = 'E2E手腳' +results = [] + +def free_port(): + s = socket.socket(); s.bind(('127.0.0.1', 0)); p = s.getsockname()[1]; s.close(); return p + +def check(name, ok, detail=''): + results.append((name, bool(ok), detail)) + print(('PASS ' if ok else 'FAIL ') + name + ('' if ok else f' — {detail}'), flush=True) + +# ---------- the scripted model ---------- +def tool_call(name, args): + return {'tool': (name, json.dumps(args, ensure_ascii=False))} + +SCRIPTS = { + 'SC_LIST': [tool_call('list_computers', {})], + 'SC_OPEN': [tool_call('computer_use_open_app', {'app': 'Calculator', 'computer': HANDS})], + 'SC_SHELL': [tool_call('shell', {'command': 'echo e2e-$((6*7))', 'computer': HANDS})], + 'SC_REDIRECT': [tool_call('shell', {'command': 'echo moved-here', 'computer': HANDS})], + 'SC_HANDOFF': [tool_call('hand_over_computer', {'instruction': 'E2E:請按「我完成了」', 'computer': HANDS})], + 'SC_OWN': [tool_call('shell', {'command': 'echo on-own-$((2+3))'})], +} + +class Model(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + def do_GET(self): + body = json.dumps({'data': [{'id': 'e2e'}]}).encode() + self.send_response(200); self.send_header('Content-Type', 'application/json'); self.send_header('Content-Length', str(len(body))); self.end_headers(); self.wfile.write(body) + def do_POST(self): + n = int(self.headers.get('Content-Length', '0') or 0) + req = json.loads(self.rfile.read(n) or b'{}') + msgs = req.get('messages', []) + last_user = max((i for i, m in enumerate(msgs) if m.get('role') == 'user'), default=-1) + text = msgs[last_user].get('content', '') if last_user >= 0 else '' + if isinstance(text, list): + text = ' '.join(p.get('text', '') for p in text if isinstance(p, dict)) + done = sum(1 for m in msgs[last_user + 1:] if m.get('role') == 'tool') + last_tool = next((m.get('content', '') for m in reversed(msgs) if m.get('role') == 'tool'), '') + script = next((v for k, v in SCRIPTS.items() if k in text), []) + if done < len(script): + name, args = script[done]['tool'] + chunk = {'choices': [{'delta': {'tool_calls': [{'index': 0, 'id': f'c{int(time.time()*1000)}', 'function': {'name': name, 'arguments': args}}]}}]} + else: + chunk = {'choices': [{'delta': {'content': 'E2E 完成:' + (last_tool if isinstance(last_tool, str) else '')[:300]}}]} + self.send_response(200); self.send_header('Content-Type', 'text/event-stream'); self.end_headers() + self.wfile.write(('data: ' + json.dumps(chunk, ensure_ascii=False) + '\n\ndata: [DONE]\n\n').encode()) + +# ---------- relay helpers ---------- +RP = free_port(); MP = free_port() +RURL = f'http://127.0.0.1:{RP}' + +def http(method, url, body=None, token=None, timeout=60, raw=False): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request(url, method=method, data=data, headers={'Content-Type': 'application/json', **({'Authorization': 'Bearer ' + token} if token else {})}) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + b = r.read(); return r.status, (b if raw else json.loads(b or b'null')) + except urllib.error.HTTPError as e: + b = e.read() + try: + return e.code, json.loads(b) + except Exception: + return e.code, b + +procs = {} +def start(name, args, env): + log = open(os.path.join(WORK, name + '.log'), 'a') + e = dict(os.environ); e.update(env) + procs[name] = subprocess.Popen(args, env=e, stdout=log, stderr=log, cwd=WORK) + +def stop(name): + p = procs.pop(name, None) + if p: + p.terminate() + try: + p.wait(10) + except Exception: + p.kill() + +def template(): + path = os.path.join(WORK, 'sample.json') + subprocess.run([BIN, '--sample-state', path], check=True) + t = json.load(open(path)) + for k in ('channels', 'mcp_servers', 'skills', 'pages', 'review_allow_rules', 'review_ask_rules', 'remote_computers', 'folders'): + if k in t: + t[k] = [] + t['providers'] = {'custom': {'api_key': '', 'base_url': f'http://127.0.0.1:{MP}/v1', 'default_model': 'e2e', 'known_models': ['e2e']}} + t['remote'] = {'lan': False, 'password': '', 'devices': [], 'mac_from_phone': False, 'device_link': False, 'relay_url': '', 'relay_user': '', 'host_id': '', 'host_token': '', 'host_name': '', 'backup_key': '', 'auto_backup': False, 'last_backup': 0} + b = t['bots'][0] + b.update({'name': 'Master', 'provider': 'Custom', 'model': 'e2e', 'messages': [], 'memories': [], 'allow_rules': [], 'last_turn': None, 'default_computer': 'Bot 的電腦', 'tools_enabled': True, 'instructions': ''}) + b.pop('threads', None) + t['bots'] = [b]; t['primary_bot'] = b['id']; t['selected'] = b['id'] + t['local_execution'] = False; t['direct_mode'] = False; t['auto_review'] = False + return t + +NOBOX = {'DOCKER_HOST': 'unix:///nonexistent/docker.sock', 'PATH': '/usr/bin:/bin:/usr/sbin:/sbin'} + +def run(): + srv = ThreadingHTTPServer(('127.0.0.1', MP), Model) + threading.Thread(target=srv.serve_forever, daemon=True).start() + os.makedirs(os.path.join(WORK, 'relay')) + start('relay', [RELAY, '--bind', '127.0.0.1', '--port', str(RP), '--data', os.path.join(WORK, 'relay')], {}) + for _ in range(50): + try: + if http('GET', RURL + '/api/hello')[0] == 200: + break + except Exception: + time.sleep(0.2) + # The brain: a seeded bot on the fake model, joins (and makes) the account. + bdir = os.path.join(WORK, 'brain'); os.makedirs(bdir) + st = template(); st['remote']['host_name'] = 'E2E大腦' + json.dump(st, open(os.path.join(bdir, 'state.json'), 'w'), ensure_ascii=False) + benv = {'ANYBOT_DATA_DIR': bdir, 'ANYBOT_ACCOUNT': f'{RURL}|{USER}', 'ANYBOT_ACCOUNT_PASSWORD': PW, 'ANYBOT_ACCOUNT_CREATE': '1', 'ANYBOT_HOST_NAME': 'E2E大腦', **NOBOX} + start('brain', [BIN, '--serve'], benv) + brain_id = None + for _ in range(60): + time.sleep(0.5) + try: + s = json.load(open(os.path.join(bdir, 'state.json'))) + if s['remote'].get('host_id'): + brain_id = s['remote']['host_id']; break + except Exception: + pass + check('brain joins the account', brain_id, 'no host id') + # The hands: a second AnyBot of the account, lending its hands to that brain only. + _, v = http('POST', RURL + '/relay/login', {'user': USER, 'password': PW, 'device': HANDS}) + _, h = http('POST', RURL + '/relay/hosts', {'name': HANDS}, token=v['token']) + hdir = os.path.join(WORK, 'hands'); os.makedirs(hdir) + hs = template(); hs['bots'] = [] + hs['remote'].update({'relay_url': RURL, 'relay_user': USER, 'host_id': h['id'], 'host_token': h['token'], 'host_name': HANDS, 'device_link': True, 'hands_for': brain_id, 'hands_for_name': 'E2E大腦'}) + json.dump(hs, open(os.path.join(hdir, 'state.json'), 'w'), ensure_ascii=False) + henv = {'ANYBOT_DATA_DIR': hdir, **NOBOX} + start('hands', [BIN, '--serve'], henv) + tok = h['token'] + sync = lambda method, rest='', body=None, q='': http(method, f'{RURL}/relay/hosts/{brain_id}/api/sync{rest}{q}', body, token=tok) + + def peers(): + return {x['name']: x for x in http('GET', RURL + '/relay/host/peers', token=tok)[1]['hosts']} + for _ in range(40): + p = peers() + if p.get('E2E大腦', {}).get('online') and p.get(HANDS, {}).get('online'): + break + time.sleep(0.5) + p = peers() + check('brain and hands are online', p.get('E2E大腦', {}).get('online') and p.get(HANDS, {}).get('online'), json.dumps(p, ensure_ascii=False)) + # Tell the account this computer shows that brain (as the Mac's screen does). + copy = {'version': 0, 'msgs': {}} + + def pull(full=False, client='screen'): + since = 0 if full else copy['version'] + code, v = sync('GET', q=f'?since={since}&client={client}') + if code != 200: + raise RuntimeError(f'pull {code} {v}') + if client == 'screen': + copy['version'] = v.get('version', 0) + for d in v.get('bots', []): + cur = copy['msgs'].setdefault(d['id'], []) + if len(cur) < d['from']: + copy['version'] = 0 # the screen asks for everything again + continue + del cur[d['from']:] + cur.extend(d['messages']) + return v + + # The brain looks the account's computers up every 15 s: a computer that just came on is + # there within that. + for _ in range(25): + v = pull() + comps = v.get('computers', {}).get('1', {}) + names = [x['name'] for x in comps.get('names', [])] + if HANDS in names: + break + time.sleep(1) + check('computers by their own names; no server computer', names == ['Master 的電腦', HANDS], names) + check('the bot works on its own computer by default', comps.get('current') == 'Master 的電腦', comps) + + def wait_turn(answer=None, timeout=90, during=None): + """Until the turn ends; answers requests with `answer` ("once"/"reject").""" + t0 = time.time(); seen = [] + while time.time() - t0 < timeout: + v = pull(client='poll') + for a in v.get('approvals', []): + key = (a.get('call_id'), a.get('kind')) + if key not in [s[:2] for s in seen]: + seen.append((a.get('call_id'), a.get('kind'), a)) + if during: + during(a) + elif answer: + sync('POST', '/act', {'bot': 1, 'action': 'answer', 'answer': answer}) + if not v.get('live') and not v.get('approvals') and time.time() - t0 > 2.5: + return seen, v + time.sleep(0.5) + return seen, None + + def last_tool(v): + msgs = [m for m in v['bots'][0]['messages'] if m['role'] == 'Tool'] if v and v.get('bots') else [] + return msgs[-1]['text'] if msgs else '' + + def full_messages(): + return pull(full=True, client='check')['bots'][0]['messages'] + + def tool_text_after(marker): + ms = full_messages() + i = max((k for k, m in enumerate(ms) if m['role'] == 'User' and marker in m['text']), default=-1) + return [m['text'] for m in ms[i + 1:] if m['role'] == 'Tool'] + + # 1. 查看電腦: the person's computer is the one they typed on. + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '我現在用哪台?SC_LIST'}) + wait_turn('once') + t = ' '.join(tool_text_after('SC_LIST')) + check('list_computers names the hands as where the person is', HANDS in t and '使用者現在就是在這台' in t, t[:300]) + # 4. Not asked for: back to the chosen computer, with a word for the bot. + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '跑一下 SC_REDIRECT'}) + wait_turn('once') + t = ' '.join(tool_text_after('SC_REDIRECT')) + check('a step the person did not ask for goes to the chosen computer, and the bot is told', 'moved-here' in t and '[Master 的電腦]' in t and '這一步你指定了' in t, t[:400]) + # 2. A step on the person's computer, named by them: asks once, runs there (agent-desktop). + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦打開計算機 SC_OPEN'}) + seen, _ = wait_turn('once') + t = ' '.join(tool_text_after('SC_OPEN')) + check('open_app on the person computer asks first', any(s[1] == 'action' for s in seen), seen) + check('open_app ran on the hands via agent-desktop', f'[{HANDS}]' in t and ('已打開' in t), t[:400]) + # 3. A command there. + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL'}) + wait_turn('once') + t = ' '.join(tool_text_after('SC_SHELL')) + check('shell runs on the hands', 'e2e-42' in t and f'[{HANDS}]' in t, t[:300]) + # 5. Its own computer. + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '用你自己的 SC_OWN'}) + wait_turn('once') + t = ' '.join(tool_text_after('SC_OWN')) + check('own computer by default', 'on-own-5' in t and '[Master 的電腦]' in t, t[:300]) + # 6. 需要處理: 我完成了. + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦 SC_HANDOFF'}) + got = {} + phone = {} + def on_handoff(a): + got.update(a) + # The phone (signed in as the person) sees it too, as 需要處理 with the computer. + _, ses = http('POST', RURL + '/relay/login', {'user': USER, 'password': PW, 'device': 'e2e-phone'}) + code, view = http('GET', f'{RURL}/h/{brain_id}/api/state?since=0', token=ses.get('token')) + phone.update(next((x for x in (view or {}).get('approvals', []) if x.get('bot') == 1), {}) if code == 200 else {'code': code, 'body': str(view)[:200]}) + sync('POST', '/act', {'bot': 1, 'action': 'answer', 'answer': 'once'}) + wait_turn(during=on_handoff) + t = ' '.join(tool_text_after('在我的電腦 SC_HANDOFF')) + check('hand-off shows as 需要處理 with the instruction and computer', got.get('kind') == 'handoff' and got.get('reason') == 'E2E:請按「我完成了」' and got.get('grant') == HANDS, got) + check('我完成了 lets the bot carry on', '我完成了' in t, t[:300]) + check('the phone shows the hand-off with its computer', phone.get('kind') == 'handoff' and phone.get('computer') == HANDS and not phone.get('can_always'), phone) + # 7. 需要處理: 跳過. + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '再一次在我的電腦 SC_HANDOFF'}) + wait_turn('reject') + t = ' '.join(tool_text_after('再一次在我的電腦 SC_HANDOFF')) + check('跳過 tells the bot the step is skipped', '跳過' in t, t[:300]) + # 8. 需要處理: answered by a message. + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '第三次在我的電腦 SC_HANDOFF'}) + def on_reply(a): + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '我已經登入好了'}) + seen, v = wait_turn(during=on_reply) + t = ' '.join(tool_text_after('第三次在我的電腦 SC_HANDOFF')) + check('a message answers a hand-off', '傳了訊息' in t, t[:300]) + ms = full_messages() + check('the reply is in the chat once', sum(1 for m in ms if m['role'] == 'User' and m['text'] == '我已經登入好了') == 1, [m['text'] for m in ms if m['role'] == 'User']) + # 9. The screen's copy, kept from small updates, matches the brain's chat exactly. + for _ in range(3): + pull() + mine = copy['msgs'].get(1, []) + full = full_messages() + sig = lambda ms: [(m['role'], m['text'], m.get('at')) for m in ms] + check('screen copy equals the brain chat (no duplicates)', sig(mine) == sig(full), f'{len(mine)} vs {len(full)}') + users = [m['text'] for m in full if m['role'] == 'User'] + check('no user message twice', len(users) == len(set(users)), users) + # 10. Choosing a computer from the screen reaches the brain. + sync('POST', '/edit', {'client': 'screen', 'seq': 1, 'ops': [{'op': 'bot_set', 'id': 1, 'key': 'default_computer', 'value': HANDS}]}) + time.sleep(1.0) + c = pull(full=True, client='check2')['computers']['1'] + check('the computer chosen on the screen is the brain\'s choice', c['current'] == HANDS, c) + sync('POST', '/edit', {'client': 'screen', 'seq': 2, 'ops': [{'op': 'bot_set', 'id': 1, 'key': 'default_computer', 'value': 'Bot 的電腦'}]}) + time.sleep(1.0) + c = pull(full=True, client='check3')['computers']['1'] + check('and back to its own', c['current'] == 'Master 的電腦', c) + # 11. The hands go away: plain words, not codes. + stop('hands') + time.sleep(10) + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL 二'}) + wait_turn('once', timeout=120) + t = ' '.join(tool_text_after('SC_SHELL 二')) + check('hands offline: the bot hears it in words', ('連不到' in t or '離線' in t) and 'e2e-42' not in t and '這一步你指定了' not in t and not any(code in t for code in ('503', '502', '404')), t[:300]) + start('hands', [BIN, '--serve'], henv) + for _ in range(40): + if peers().get(HANDS, {}).get('online'): + break + time.sleep(0.5) + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL 三'}) + wait_turn('once') + t = ' '.join(tool_text_after('SC_SHELL 三')) + check('hands back: works again', 'e2e-42' in t and f'[{HANDS}]' in t, t[:300]) + # 12. The brain restarts: the screen picks everything up again, and work goes on. + before = len(full_messages()) + stop('brain') + time.sleep(3) + start('brain', [BIN, '--serve'], benv) + for _ in range(60): + try: + if peers().get('E2E大腦', {}).get('online'): + v = pull() + break + except Exception: + pass + time.sleep(0.5) + time.sleep(2) + pull() + check('after a brain restart the screen has the whole chat', len(copy['msgs'].get(1, [])) >= before, f'{len(copy["msgs"].get(1, []))} < {before}') + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL 四'}) + wait_turn('once') + t = ' '.join(tool_text_after('SC_SHELL 四')) + check('after a brain restart the hands still work', 'e2e-42' in t and f'[{HANDS}]' in t, t[:300]) + pull(); pull() + check('screen copy still equals the brain chat', sig(copy['msgs'].get(1, [])) == sig(full_messages())) + # 13. 清除聊天記錄 from the screen clears the brain's chat. + sync('POST', '/act', {'bot': 1, 'action': 'clear'}) + time.sleep(1.5) + check('clear from the screen empties the brain chat', full_messages() == []) + pull(); pull() + check('and the screen copy follows', copy['msgs'].get(1, []) == [], len(copy['msgs'].get(1, []))) + +try: + run() +except Exception as e: + import traceback; traceback.print_exc() + check('harness ran to the end', False, repr(e)) +finally: + for n in list(procs): + stop(n) + failed = [r for r in results if not r[1]] + print(f'\n{len(results) - len(failed)}/{len(results)} passed; logs in {WORK}') + sys.exit(1 if failed else 0) diff --git a/src/agent.rs b/src/agent.rs index 0c31819..7ae3c57 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -112,9 +112,7 @@ fn drain_steer(job: &Job, history: &mut Vec, send: &dyn Fn(StreamEvent) fn target_for(job: &Job, args: &str) -> Result { let v = tools::args_of(args); let want = v.get("computer").and_then(|c| c.as_str()).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string(); - job.computers.iter().find(|t| t.name == want).cloned().ok_or_else(|| { - format!("沒有「{want}」這台電腦。可以用:{}", job.computers.iter().map(|t| t.name.as_str()).collect::>().join("、")) - }) + job.computers.iter().find(|t| t.name == want).cloned().ok_or_else(|| not_here(job, &want)) } enum BrowserAt { @@ -225,6 +223,9 @@ fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> (String, Option let said = history.iter().rev().filter(|m| m.role == Role::User && !m.summary).take(3).map(|m| m.text.as_str()).collect::>().join("\n"); match job.computers.iter().find(|t| t.name == named) { Some(t) if mentions(&said, t, &job.roles) => (args.to_string(), None), + // A computer that isn't on the list now (switched off, its AnyBot closed): the step says + // so instead of quietly running somewhere else. + None => (args.to_string(), None), _ => { v["computer"] = serde_json::Value::String(job.default_computer.clone()); let note = format!("(這一步你指定了「{named}」,但使用者選的電腦是「{}」,而且他沒有說要換,所以這一步在「{}」上做。真的需要別台,先問使用者。)\n", job.default_computer, job.default_computer); @@ -241,6 +242,11 @@ fn noted(moved: &std::collections::HashMap, id: &str, text: &str } } +/// A computer the step names that isn't among this bot's computers right now, in plain words. +fn not_here(job: &Job, name: &str) -> String { + format!("「{name}」現在連不到:那台電腦可能沒開,或上面的 AnyBot 沒開、沒登入。請告訴使用者,等它連上再做(或問他要不要改用別台)。現在可以用:{}", job.computers.iter().map(|t| t.name.as_str()).collect::>().join("、")) +} + /// Does the call name a computer? fn names_a_computer(args: &str) -> bool { tools::args_of(args).get("computer").and_then(|c| c.as_str()).map(|c| !c.trim().is_empty()).unwrap_or(false) @@ -254,7 +260,7 @@ fn screen_target(job: &Job, args: &str) -> Result { let has_screen = |t: &tools::Target| matches!(t.place, tools::Place::Box { .. } | tools::Place::Local { .. } | tools::Place::Device { .. }); let named = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty()); if let Some(n) = named { - let t = job.computers.iter().find(|t| t.name == n).ok_or_else(|| format!("沒有「{n}」這台電腦。可以用:{}", job.computers.iter().map(|t| t.name.as_str()).collect::>().join("、")))?; + let t = job.computers.iter().find(|t| t.name == n).ok_or_else(|| not_here(job, n))?; if !has_screen(t) { return Err(if n == job.roles.own { format!("{n}現在沒有螢幕:AnyBot 所在的電腦沒有 Docker({})。要用別台請使用者指名。", if cfg!(target_os = "macos") { "安裝 OrbStack 或 Docker Desktop 就會有" } else { "安裝 Docker Engine 就會有" }) diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs index 7585c6b..c0668cc 100644 --- a/src/app/remote_ui.rs +++ b/src/app/remote_ui.rs @@ -131,8 +131,9 @@ impl App { if !self.mir.on && s.auto_backup && !s.backup_key.is_empty() && !s.relay_url.is_empty() && chrono::Utc::now().timestamp() - s.last_backup > 86_400 && !self.rm.busy.load(Ordering::SeqCst) { self.remote_backup(); } - // 裝置連結: keep the list of this account's computers fresh (once a minute). - if !self.state.remote.host_token.is_empty() && !self.rm.peers_at.map(|t| t.elapsed().as_secs() < 60).unwrap_or(false) { + // 裝置連結: keep the list of this account's computers fresh (every 15 s: a computer that + // comes on is one the bots can use soon after). + if !self.state.remote.host_token.is_empty() && !self.rm.peers_at.map(|t| t.elapsed().as_secs() < 15).unwrap_or(false) { self.rm.peers_at = Some(Instant::now()); let (peers, s) = (self.rm.peers.clone(), self.state.remote.clone()); std::thread::spawn(move || { @@ -268,6 +269,15 @@ impl App { // 「我的電腦」 and the model on their network). The phone isn't a computer. if !from.is_empty() { remote::set_person_host(&from); + // A computer this brain hasn't heard of yet (it just came on): look the + // account's computers up now, or its 「我的電腦」 wouldn't be known for a minute. + if !self.rm.peers.lock().unwrap().iter().any(|(id, _, online)| *id == from && *online) { + let v = remote::devices_online(&self.state.remote); + if !v.is_empty() { + *self.rm.peers.lock().unwrap() = v; + } + self.rm.peers_at = Some(Instant::now()); + } self.typed_on = Some(from); } // Only files the app itself stored in its attachments folder may ride along; diff --git a/src/cloudbox.rs b/src/cloudbox.rs index 10540a9..1d20611 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -41,8 +41,9 @@ pub const HOST_PORT: u16 = 16080; pub const SCREEN: &str = "1280x800x24"; /// Health probe timeout (QQn). pub const HEALTH_TIMEOUT_MS: u64 = 3_000; -/// Snapshot ("flush the durable store") timeout (VQn). -pub const SNAPSHOT_TIMEOUT_MS: u64 = 30_000; +/// Saving a snapshot ("flush the durable store"). The original waits 30 s (VQn); a home folder of +/// a GB with an open browser takes minutes to pack, and a cut-off snapshot is no snapshot. +pub const SNAPSHOT_SAVE_TIMEOUT_MS: u64 = 600_000; /// Restore copy-in: longest wait and poll interval (zQn, HQn). pub const COPY_IN_WAIT_MS: u64 = 90_000; pub const COPY_IN_POLL_MS: u64 = 2_000; @@ -747,7 +748,11 @@ pub fn save_snapshot() -> Result { let tmp = data_dir().join("snapshot.tmp"); let file = std::fs::File::create(&tmp).map_err(|e| e.to_string())?; let mut child = docker() - .args(["exec", CONTAINER, "tar", "-C", HOME, "--exclude=./.cache", "-czf", "-", "."]) + // Caches are left out: the browser's grow to hundreds of MB and come back by themselves. + .args([ + "exec", CONTAINER, "tar", "-C", HOME, "--exclude=./.cache", "--exclude=Cache", "--exclude=Code Cache", "--exclude=GPUCache", "--exclude=CacheStorage", "--exclude=ScriptCache", "--exclude=Crashpad", + "--warning=no-file-changed", "-czf", "-", ".", + ]) .stdout(Stdio::from(file)) .stderr(Stdio::piped()) .spawn() @@ -763,10 +768,12 @@ pub fn save_snapshot() -> Result { }); let start = Instant::now(); let ok = loop { + // tar says 1 when a file changed while it was read (the open browser writes all the + // time): the archive is still whole. Only 2 and up are failures. if let Ok(Some(st)) = child.try_wait() { - break st.success(); + break st.code().map(|c| c <= 1).unwrap_or(false); } - if start.elapsed() > Duration::from_millis(SNAPSHOT_TIMEOUT_MS * 4) { + if start.elapsed() > Duration::from_millis(SNAPSHOT_SAVE_TIMEOUT_MS) { let _ = child.kill(); break false; } @@ -1527,15 +1534,29 @@ mod tests { println!("{txt}"); assert!(img.is_some()); run_on("shell", r#"{"command":"(xterm >/dev/null 2>&1 &) ; sleep 1.5"}"#, &t, &cancel, &ids); - computer_run("computer_use_click", &serde_json::json!({"x": 200, "y": 150})).unwrap(); + // Click the middle of that window, wherever it opened (other windows — a browser left + // open — may cover the corner), in the screenshot's pixels. (Its shell renames it, so it + // is found by its class: the newest xterm.) + let (geo, _) = run_on("shell", r#"{"command":"W=$(DISPLAY=:1 xdotool search --onlyvisible --class xterm | tail -1); DISPLAY=:1 xdotool getwindowgeometry --shell $W | tail -n +2 | head -4 | tr '\n' ' '"}"#, &t, &cancel, &ids); + let num = |k: &str| geo.split_whitespace().find_map(|w| w.strip_prefix(&format!("{k}=")).and_then(|v| v.parse::().ok())).unwrap_or(0.0); + let (cx, cy) = (num("X") + num("WIDTH") / 2.0, num("Y") + num("HEIGHT") / 2.0); + let k = 1024.0 / 1280.0; + computer_run("computer_use_click", &serde_json::json!({"x": cx * k, "y": cy * k})).unwrap(); computer_run("computer_use_typing", &serde_json::json!({"value": "echo 中文輸入OK > /home/bot/typed.txt"})).unwrap(); computer_run("computer_use_press_key", &serde_json::json!({"key": "return"})).unwrap(); std::thread::sleep(Duration::from_millis(800)); let (out, _) = run_on("shell", r#"{"command":"cat /home/bot/typed.txt"}"#, &t, &cancel, &ids); println!("typed: {out}"); assert!(out.contains("中文輸入OK"), "{out}"); - // snapshot + restore - save_snapshot().unwrap(); + // snapshot (with the browser and its caches in the home folder), then restore — which + // rebuilds the whole box, so only on a box meant for it (ANYBOT_TEST_RESTORE=1). + let size = save_snapshot().expect("snapshot saves even while the browser writes"); + println!("snapshot: {}", human(size)); + assert!(size > 0); + if std::env::var("ANYBOT_TEST_RESTORE").as_deref() != Ok("1") { + let _ = run_on("shell", r#"{"command":"rm -rf /home/bot/bot-999 /home/bot/typed.txt"}"#, &t, &cancel, &ids); + return; + } run_on("shell", r#"{"command":"rm -rf notes"}"#, &t, &cancel, &ids); rebuild(Rebuild::Restore).unwrap(); let start = Instant::now(); diff --git a/src/main.rs b/src/main.rs index e1b1e19..40b466e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -177,6 +177,17 @@ fn write_icon(out: &str, px: u32) -> Result<(), String> { fn main() -> eframe::Result<()> { let args: Vec = std::env::args().collect(); let screenshot = args.iter().position(|a| a == "--screenshot").and_then(|i| args.get(i + 1)).map(PathBuf::from); + // For tests that start AnyBots of their own: a whole settings file (the sample bots) to edit. + if args.get(1).map(String::as_str) == Some("--sample-state") { + let out = args.get(2).cloned().unwrap_or_else(|| "state.json".into()); + let mut st = model::State::samples(); + st.remote = Default::default(); + if let Err(e) = std::fs::write(&out, serde_json::to_vec_pretty(&st).unwrap_or_default()) { + eprintln!("{e}"); + std::process::exit(1); + } + return Ok(()); + } if args.get(1).map(String::as_str) == Some("--write-icon") { let out = args.get(2).cloned().unwrap_or_else(|| "anybot.png".into()); let px = args.get(3).and_then(|v| v.parse().ok()).unwrap_or(256);