izteamslots 1.5.2 → 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [1.5.3](https://github.com/izzzzzi/izTeamSlots/compare/v1.5.2...v1.5.3) (2026-03-06)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * infrastructure hardening — atomic writes, DATA_ROOT, emergency exit, virtual scroll ([683dfd6](https://github.com/izzzzzi/izTeamSlots/commit/683dfd6670b0d4431b639331f2bc205f2fd8348a))
7
+
1
8
  ## [1.5.2](https://github.com/izzzzzi/izTeamSlots/compare/v1.5.1...v1.5.2) (2026-03-06)
2
9
 
3
10
 
package/README.md CHANGED
@@ -36,15 +36,12 @@
36
36
  - Перелогин слотов: одного выбранного или всех по очереди.
37
37
  - Codex-файлы: авто-сохранение `codex-<email>-Team.json` в аккаунт и в `./codex/`.
38
38
  - Doctor-проверка: валидация/восстановление файловой структуры аккаунтов при старте.
39
- - Просмотр почты: входящие письма для админов и слотов.
40
-
41
39
  ---
42
40
 
43
41
  ## Структура проекта
44
42
 
45
43
  ```text
46
44
  izTeamSlots/
47
- ├── app.py # Entrypoint: запускает UI
48
45
  ├── bin/ # CLI-бинарники
49
46
  │ └── izteamslots.mjs # Кроссплатформенный entrypoint (Node.js)
50
47
  ├── scripts/ # Установочные скрипты
@@ -147,17 +144,17 @@ izteamslots
147
144
 
148
145
  ```mermaid
149
146
  flowchart TD
150
- A[app.py] -->|spawns| B[bun ui/src/main.ts]
147
+ A[izteamslots CLI] -->|bun| B[ui/src/main.ts]
151
148
  B --> C[MainScreen.ts]
152
- C -->|stdio JSON-RPC| D[StdioRpcClient]
153
- D -->|spawns| E[python -m backend]
149
+ C --> D[StdioRpcClient]
150
+ D -->|spawns + stdio JSON-RPC| E[python -m backend]
154
151
  E --> F[RPCServer]
155
152
  F --> G[UIFacade]
156
153
  G --> H[AccountStore]
157
154
  G --> I[SlotManager]
158
155
  I --> J[openai_web_auth]
159
156
  I --> K[chatgpt_workspace_api]
160
- I --> L[Boomlify Mail API]
157
+ I --> L[Mail Providers]
161
158
  ```
162
159
 
163
160
  ## Почтовые провайдеры (плагины)
@@ -1,3 +1,16 @@
1
+ import os
1
2
  from pathlib import Path
2
3
 
3
4
  PROJECT_ROOT = Path(__file__).resolve().parent.parent
5
+
6
+
7
+ def _resolve_data_root() -> Path:
8
+ env = os.environ.get("IZTEAMSLOTS_DATA")
9
+ if env:
10
+ return Path(env)
11
+ if "node_modules" in str(PROJECT_ROOT):
12
+ return Path.home() / ".izteamslots"
13
+ return PROJECT_ROOT
14
+
15
+
16
+ DATA_ROOT = _resolve_data_root()
@@ -9,9 +9,9 @@ from datetime import datetime, timezone
9
9
  from pathlib import Path
10
10
  from typing import Any
11
11
 
12
- from . import PROJECT_ROOT
12
+ from . import DATA_ROOT, PROJECT_ROOT
13
13
 
14
- ACCOUNTS_DIR = PROJECT_ROOT / "accounts"
14
+ ACCOUNTS_DIR = DATA_ROOT / "accounts"
15
15
 
16
16
 
17
17
  @dataclass
@@ -60,8 +60,7 @@ class AccountStore:
60
60
  return {}
61
61
 
62
62
  def _write_index(self, path: Path, data: dict) -> None:
63
- index_path = path / "index.json"
64
- index_path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
63
+ self._atomic_write_json(path / "index.json", data)
65
64
 
66
65
  def _read_meta(self, folder: Path) -> dict:
67
66
  meta_path = folder / "meta.json"
@@ -71,8 +70,13 @@ class AccountStore:
71
70
 
72
71
  def _write_meta(self, folder: Path, data: dict) -> None:
73
72
  folder.mkdir(parents=True, exist_ok=True)
74
- meta_path = folder / "meta.json"
75
- meta_path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
73
+ self._atomic_write_json(folder / "meta.json", data)
74
+
75
+ def _atomic_write_json(self, path: Path, data: dict) -> None:
76
+ """Write JSON atomically: temp file + rename to prevent corruption on crash."""
77
+ tmp = path.with_suffix(".tmp")
78
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
79
+ tmp.replace(path)
76
80
 
77
81
  # --- Admin CRUD ---
78
82
 
@@ -226,6 +230,7 @@ class AccountStore:
226
230
  "email": email,
227
231
  "password": password,
228
232
  "status": "created",
233
+ "admin_email": admin_email,
229
234
  })
230
235
  return WorkerAccount(
231
236
  id=worker_id, email=email, password=password,
@@ -256,6 +261,8 @@ class AccountStore:
256
261
  meta["access_token"] = account.access_token
257
262
  if account.workspace_id is not None:
258
263
  meta["workspace_id"] = account.workspace_id
264
+ if account.admin_email is not None:
265
+ meta["admin_email"] = account.admin_email
259
266
  self._write_meta(account_dir, meta)
260
267
 
261
268
  def delete_worker(self, email: str) -> None:
@@ -327,17 +334,24 @@ class AccountStore:
327
334
  meta = {"email": email, "password": ""}
328
335
  if role == "worker":
329
336
  meta["status"] = info.get("status", "created")
337
+ meta["admin_email"] = info.get("admin_email", "")
330
338
  self._write_meta(account_dir, meta)
331
339
  fixes.append(f"{role}: {email} — meta.json отсутствовал, создан заново")
332
340
  else:
333
341
  try:
334
342
  json.loads(meta_path.read_text())
335
343
  except (json.JSONDecodeError, OSError):
344
+ backup = meta_path.with_suffix(".json.bak")
345
+ try:
346
+ meta_path.replace(backup)
347
+ except OSError:
348
+ pass
336
349
  meta = {"email": email, "password": ""}
337
350
  if role == "worker":
338
351
  meta["status"] = info.get("status", "created")
352
+ meta["admin_email"] = info.get("admin_email", "")
339
353
  self._write_meta(account_dir, meta)
340
- fixes.append(f"{role}: {email} — meta.json был повреждён, пересоздан")
354
+ fixes.append(f"{role}: {email} — meta.json повреждён, .bak сохранён")
341
355
 
342
356
  # 4. browser_profile отсутствует (данные есть, но куки нет)
343
357
  for email, info in index.items():
@@ -374,9 +388,21 @@ class AccountStore:
374
388
  changed = True
375
389
  fixes.append(f"{role}: папка {child.name} ({email}) — добавлена в index")
376
390
  elif not email:
377
- # Нет meta или нет email удаляем сироту
378
- shutil.rmtree(child)
379
- fixes.append(f"{role}: папка-сирота {child.name} без meta удалена")
391
+ fixes.append(f"{role}: папка-сирота {child.name} без metaтребует ручной проверки")
392
+
393
+ # Миграция: admin_email из index в meta.json для воркеров
394
+ if role == "worker":
395
+ for w_email, w_info in index.items():
396
+ w_admin = w_info.get("admin_email", "")
397
+ w_id = w_info.get("id")
398
+ if not w_admin or not w_id:
399
+ continue
400
+ w_dir = role_dir / w_id
401
+ w_meta = self._read_meta(w_dir)
402
+ if w_meta and not w_meta.get("admin_email"):
403
+ w_meta["admin_email"] = w_admin
404
+ self._write_meta(w_dir, w_meta)
405
+ fixes.append(f"worker: {w_email} — admin_email мигрирован в meta.json")
380
406
 
381
407
  if changed or to_remove:
382
408
  self._write_index(role_dir, index)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import time
4
5
  from typing import Any
5
6
 
6
7
  from .openai_web_auth import Page
@@ -31,34 +32,51 @@ class ChatGPTWorkspaceAPI:
31
32
  url = f"https://chatgpt.com{path}"
32
33
  js_body = json.dumps(body) if body else "null"
33
34
 
34
- result = self.page.evaluate(
35
- """async ([url, method, body, token, accountId]) => {
36
- const opts = {
37
- method: method,
38
- headers: {
39
- "Content-Type": "application/json",
40
- "Authorization": "Bearer " + token,
41
- "chatgpt-account-id": accountId,
42
- },
43
- };
44
- if (body && method !== 'GET' && method !== 'HEAD') opts.body = body;
45
- const resp = await fetch(url, opts);
46
- const text = await resp.text();
47
- return {status: resp.status, body: text};
48
- }""",
49
- [url, method, js_body, self.access_token, self.account_id],
50
- )
51
-
52
- status = result["status"]
53
- raw_body = result["body"]
54
-
55
- if status >= 400:
56
- short = raw_body[:200] if len(raw_body) > 200 else raw_body
57
- if status == 403:
58
- short = "Cloudflare/доступ запрещён токен протух, перелогинитесь"
59
- raise ChatGPTAPIError(status, short)
60
-
61
- return json.loads(raw_body) if raw_body else {}
35
+ max_attempts = 3
36
+ for attempt in range(1, max_attempts + 1):
37
+ try:
38
+ result = self.page.evaluate(
39
+ """async ([url, method, body, token, accountId]) => {
40
+ const opts = {
41
+ method: method,
42
+ headers: {
43
+ "Content-Type": "application/json",
44
+ "Authorization": "Bearer " + token,
45
+ "chatgpt-account-id": accountId,
46
+ },
47
+ };
48
+ if (body && method !== 'GET' && method !== 'HEAD') opts.body = body;
49
+ const resp = await fetch(url, opts);
50
+ const text = await resp.text();
51
+ return {status: resp.status, body: text};
52
+ }""",
53
+ [url, method, js_body, self.access_token, self.account_id],
54
+ )
55
+ except Exception as e:
56
+ if attempt < max_attempts:
57
+ time.sleep(2 ** attempt)
58
+ continue
59
+ raise ChatGPTAPIError(0, f"Browser error: {e}") from e
60
+
61
+ status = result["status"]
62
+ raw_body = result["body"]
63
+
64
+ if status in (429, 500, 502, 503, 504):
65
+ if attempt < max_attempts:
66
+ time.sleep(2 ** attempt)
67
+ continue
68
+ short = raw_body[:200] if len(raw_body) > 200 else raw_body
69
+ raise ChatGPTAPIError(status, short)
70
+
71
+ if status >= 400:
72
+ short = raw_body[:200] if len(raw_body) > 200 else raw_body
73
+ if status == 403:
74
+ short = "Cloudflare/доступ запрещён — токен протух, перелогинитесь"
75
+ raise ChatGPTAPIError(status, short)
76
+
77
+ return json.loads(raw_body) if raw_body else {}
78
+
79
+ raise ChatGPTAPIError(0, "Max retries exceeded")
62
80
 
63
81
  def send_invites(self, emails: list[str]) -> dict:
64
82
  """Отправить инвайты в workspace."""
package/backend/dto.py CHANGED
@@ -90,17 +90,10 @@ class WorkerRow:
90
90
  )
91
91
 
92
92
 
93
- @dataclass
94
- class MailAccountRow:
95
- kind: str
96
- email: str
97
-
98
-
99
93
  @dataclass
100
94
  class AppStateDTO:
101
95
  admins: list[AdminRow]
102
96
  workers: list[WorkerRow]
103
- accounts: list[MailAccountRow]
104
97
 
105
98
  def to_dict(self) -> dict:
106
99
  return asdict(self)
@@ -7,7 +7,7 @@ from datetime import datetime
7
7
  from pathlib import Path
8
8
  from typing import Any
9
9
 
10
- from . import PROJECT_ROOT
10
+ from . import DATA_ROOT
11
11
 
12
12
 
13
13
  def _timestamp() -> str:
@@ -23,7 +23,7 @@ def _safe_title(value: str) -> str:
23
23
 
24
24
  class FileLogger:
25
25
  def __init__(self, root: Path | None = None) -> None:
26
- self.root = root or (PROJECT_ROOT / "logs")
26
+ self.root = root or (DATA_ROOT / "logs")
27
27
  self.jobs_dir = self.root / "jobs"
28
28
  self.app_log = self.root / "app.log"
29
29
  self._lock = threading.Lock()
@@ -49,7 +49,7 @@ class FileLogger:
49
49
  stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
50
50
  filename = f"{stamp}-{job_id[:8]}-{_safe_title(title)}.log"
51
51
  path = self.jobs_dir / filename
52
- rel_path = path.relative_to(PROJECT_ROOT).as_posix()
52
+ rel_path = path.relative_to(DATA_ROOT).as_posix()
53
53
  logger = JobFileLogger(path=path, rel_path=rel_path, title=title, root_logger=self)
54
54
  logger.log(f"JOB START: {title}")
55
55
  self.info(f"Job created: {title} [{job_id}] -> {rel_path}")
package/backend/jobs.py CHANGED
@@ -44,8 +44,19 @@ class JobManager:
44
44
  def __init__(self, emit: EmitFunc, file_logger: FileLogger | None = None) -> None:
45
45
  self._emit = emit
46
46
  self._file_logger = file_logger or FileLogger()
47
+ self._active_thread: threading.Thread | None = None
48
+ self._active_job_id: str | None = None
49
+ self._lock = threading.Lock()
50
+
51
+ @property
52
+ def busy(self) -> bool:
53
+ return self._active_thread is not None and self._active_thread.is_alive()
47
54
 
48
55
  def start(self, title: str, handler: Callable[[JobContext], Any]) -> str:
56
+ with self._lock:
57
+ if self.busy:
58
+ raise RuntimeError(f"Задача уже выполняется: {self._active_job_id}")
59
+
49
60
  job_id = uuid.uuid4().hex
50
61
  job_logger = self._file_logger.create_job_logger(job_id, title)
51
62
  self._emit("job.started", {"job_id": job_id, "title": title, "log_path": job_logger.rel_path})
@@ -73,4 +84,12 @@ class JobManager:
73
84
 
74
85
  thread = threading.Thread(target=runner, daemon=True)
75
86
  thread.start()
87
+ self._active_thread = thread
88
+ self._active_job_id = job_id
76
89
  return job_id
90
+
91
+ def wait_all(self, timeout: float = 30) -> None:
92
+ """Wait for the active job to finish."""
93
+ thread = self._active_thread
94
+ if thread and thread.is_alive():
95
+ thread.join(timeout=timeout)
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import os
5
+ import time
5
6
  from typing import Any
6
7
  from urllib.parse import urlencode
7
8
 
@@ -91,23 +92,33 @@ class BoomlifyProvider(MailProvider):
91
92
  if clean_query:
92
93
  url = f"{url}?{urlencode(clean_query)}"
93
94
 
94
- try:
95
- resp = self._session.request(method, url, json=json_body, timeout=30)
96
- except requests.RequestException as e:
97
- raise MailServiceUnavailable(f"Connection error: {e}") from e
98
-
99
- try:
100
- data = resp.json()
101
- except ValueError:
102
- data = {"error": resp.text.strip()[:500]}
103
-
104
- if resp.status_code in (401, 403):
105
- raise MailAuthError(f"[{resp.status_code}] {data}")
106
- if resp.status_code in (429, 500, 502, 503, 504):
107
- raise MailServiceUnavailable(f"[{resp.status_code}] {data}")
108
- if resp.status_code >= 400:
109
- raise MailError(f"[{resp.status_code}] {data}")
110
- return data
95
+ max_attempts = 3
96
+ for attempt in range(1, max_attempts + 1):
97
+ try:
98
+ resp = self._session.request(method, url, json=json_body, timeout=30)
99
+ except requests.RequestException as e:
100
+ if attempt < max_attempts:
101
+ time.sleep(2 ** attempt)
102
+ continue
103
+ raise MailServiceUnavailable(f"Connection error: {e}") from e
104
+
105
+ try:
106
+ data = resp.json()
107
+ except ValueError:
108
+ data = {"error": resp.text.strip()[:500]}
109
+
110
+ if resp.status_code in (401, 403):
111
+ raise MailAuthError(f"[{resp.status_code}] {data}")
112
+ if resp.status_code in (429, 500, 502, 503, 504):
113
+ if attempt < max_attempts:
114
+ time.sleep(2 ** attempt)
115
+ continue
116
+ raise MailServiceUnavailable(f"[{resp.status_code}] {data}")
117
+ if resp.status_code >= 400:
118
+ raise MailError(f"[{resp.status_code}] {data}")
119
+ return data
120
+
121
+ raise MailServiceUnavailable("Max retries exceeded")
111
122
 
112
123
  def _extract_mailbox_id(self, mailbox: Mailbox) -> str:
113
124
  password = mailbox.password.strip()
@@ -21,6 +21,7 @@ import email as email_lib
21
21
  import imaplib
22
22
  import os
23
23
  import re
24
+ import time
24
25
  from email.header import decode_header as _decode_header
25
26
  from typing import Any
26
27
 
@@ -128,22 +129,29 @@ class IMAPProvider(MailProvider):
128
129
 
129
130
  def _connect(self, mailbox: Mailbox) -> imaplib.IMAP4_SSL | imaplib.IMAP4:
130
131
  """Open an IMAP connection and authenticate."""
131
- try:
132
- if self.use_ssl:
133
- conn = imaplib.IMAP4_SSL(self.host, self.port, timeout=self.timeout)
134
- else:
135
- conn = imaplib.IMAP4(self.host, self.port, timeout=self.timeout)
136
- except (OSError, imaplib.IMAP4.error) as e:
137
- raise MailServiceUnavailable(f"Cannot connect to {self.host}:{self.port}: {e}") from e
132
+ max_attempts = 3
133
+ for attempt in range(1, max_attempts + 1):
134
+ try:
135
+ if self.use_ssl:
136
+ conn = imaplib.IMAP4_SSL(self.host, self.port, timeout=self.timeout)
137
+ else:
138
+ conn = imaplib.IMAP4(self.host, self.port, timeout=self.timeout)
139
+ except (OSError, imaplib.IMAP4.error) as e:
140
+ if attempt < max_attempts:
141
+ time.sleep(2 ** attempt)
142
+ continue
143
+ raise MailServiceUnavailable(f"Cannot connect to {self.host}:{self.port}: {e}") from e
138
144
 
139
- try:
140
- conn.login(mailbox.email, mailbox.password)
141
- except imaplib.IMAP4.error as e:
142
- err_msg = str(e)
143
- conn.logout()
144
- raise MailAuthError(f"IMAP login failed for {mailbox.email}: {err_msg}") from e
145
+ try:
146
+ conn.login(mailbox.email, mailbox.password)
147
+ except imaplib.IMAP4.error as e:
148
+ err_msg = str(e)
149
+ conn.logout()
150
+ raise MailAuthError(f"IMAP login failed for {mailbox.email}: {err_msg}") from e
151
+
152
+ return conn
145
153
 
146
- return conn
154
+ raise MailServiceUnavailable(f"Cannot connect to {self.host}:{self.port}")
147
155
 
148
156
  def generate(self) -> Mailbox:
149
157
  """IMAP cannot create mailboxes automatically.
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import json
5
5
  import re
6
+ import time
6
7
  from typing import Any
7
8
 
8
9
  import requests
@@ -78,27 +79,38 @@ class TrickAdsProvider(MailProvider):
78
79
  endpoint: str,
79
80
  json_body: dict[str, Any] | None = None,
80
81
  ) -> dict[str, Any]:
81
- try:
82
- resp = self._session.post(f"{BASE_URL}{endpoint}", json=json_body)
82
+ max_attempts = 3
83
+ for attempt in range(1, max_attempts + 1):
84
+ try:
85
+ resp = self._session.post(f"{BASE_URL}{endpoint}", json=json_body, timeout=30)
86
+ except requests.RequestException as e:
87
+ if attempt < max_attempts:
88
+ time.sleep(2 ** attempt)
89
+ continue
90
+ raise MailServiceUnavailable(f"Connection error: {e}") from e
91
+
83
92
  if resp.status_code != 200:
84
93
  body = resp.text or ""
85
94
  summary = _extract_error_summary(body)
86
95
  if resp.status_code == 401:
87
96
  raise MailAuthError(f"[{resp.status_code}] {summary}")
88
97
  if resp.status_code >= 500:
98
+ if attempt < max_attempts:
99
+ time.sleep(2 ** attempt)
100
+ continue
89
101
  raise MailServiceUnavailable(f"[{resp.status_code}] {summary}")
90
102
  raise MailError(f"[{resp.status_code}] {summary}")
103
+
91
104
  data: dict[str, Any] = resp.json()
92
- except requests.RequestException as e:
93
- raise MailServiceUnavailable(f"Connection error: {e}") from e
94
-
95
- if data.get("status") != "success":
96
- msg = data.get("message", "")
97
- code = data.get("code", 0)
98
- if code == 401 or "password" in msg.lower() or "unauthorized" in str(data.get("status", "")).lower():
99
- raise MailAuthError(f"API: {data}")
100
- raise MailError(f"API: {data}")
101
- return data
105
+ if data.get("status") != "success":
106
+ msg = data.get("message", "")
107
+ code = data.get("code", 0)
108
+ if code == 401 or "password" in msg.lower() or "unauthorized" in str(data.get("status", "")).lower():
109
+ raise MailAuthError(f"API: {data}")
110
+ raise MailError(f"API: {data}")
111
+ return data
112
+
113
+ raise MailServiceUnavailable("Max retries exceeded")
102
114
 
103
115
  def generate(self) -> Mailbox:
104
116
  data = self._request("/tepmail/generate")
@@ -116,19 +116,6 @@ class RPCServer:
116
116
  if m == "state.get":
117
117
  return make_success_response(req.request_id, self.facade.get_state())
118
118
 
119
- if m == "admins.list":
120
- return make_success_response(req.request_id, {"items": self.facade.list_admins()})
121
-
122
- if m == "workers.list":
123
- return make_success_response(req.request_id, {"items": self.facade.list_workers()})
124
-
125
- if m == "workers.by_admin":
126
- email = self._as_str_param(p, "admin_email")
127
- return make_success_response(req.request_id, {"items": self.facade.list_workers_by_admin(email)})
128
-
129
- if m == "accounts.mail.list":
130
- return make_success_response(req.request_id, {"items": self.facade.list_mail_accounts()})
131
-
132
119
  if m == "admin.add":
133
120
  email = self._as_str_param(p, "email")
134
121
  password = self._as_str_param(p, "password")
@@ -215,14 +202,6 @@ class RPCServer:
215
202
  )
216
203
  return make_success_response(req.request_id, {"job_id": job_id})
217
204
 
218
- if m == "job.fetch_mail":
219
- email = self._as_str_param(p, "email")
220
- job_id = self._run_job(
221
- f"Почта: {email}",
222
- lambda ctx: self.facade.fetch_mail(email, ctx.log),
223
- )
224
- return make_success_response(req.request_id, {"job_id": job_id})
225
-
226
205
  if m == "settings.get":
227
206
  return make_success_response(req.request_id, self._get_settings())
228
207
 
@@ -235,6 +214,7 @@ class RPCServer:
235
214
  return make_success_response(req.request_id, {"ok": True})
236
215
 
237
216
  if m == "shutdown":
217
+ self.jobs.wait_all(timeout=10)
238
218
  self.facade.shutdown()
239
219
  return make_success_response(req.request_id, {"ok": True})
240
220
 
@@ -293,7 +273,6 @@ def main() -> int:
293
273
  for env_path in (home_env, cwd_env, pkg_env):
294
274
  if env_path.is_file():
295
275
  load_dotenv(env_path)
296
- break
297
276
 
298
277
  server = RPCServer()
299
278
  server.serve()
@@ -286,8 +286,8 @@ class SlotManager:
286
286
  return worker
287
287
 
288
288
  def _cleanup_failed_worker(self, worker: WorkerAccount, api: ChatGPTWorkspaceAPI) -> None:
289
- """Удалить worker из workspace если регистрация не удалась."""
290
- self._log(f"Очистка: удаляю {worker.email} из workspace...")
289
+ """Удалить worker из workspace и локальных данных если регистрация не удалась."""
290
+ self._log(f"Очистка: удаляю {worker.email}...")
291
291
  try:
292
292
  members = api.get_members()
293
293
  for m in members:
@@ -296,14 +296,18 @@ class SlotManager:
296
296
  self._log(f"Удалён из workspace: {worker.email}")
297
297
  break
298
298
  else:
299
- # Попробуем удалить инвайт если ещё не зарегистрирован
300
299
  try:
301
300
  api.delete_invite(worker.email)
302
301
  self._log(f"Инвайт удалён: {worker.email}")
303
302
  except Exception:
304
303
  pass
305
304
  except Exception as ex:
306
- self._log(f"Не удалось очистить: {ex}")
305
+ self._log(f"Не удалось очистить workspace: {ex}")
306
+ try:
307
+ self.store.delete_worker(worker.email)
308
+ self._log(f"Локальная запись удалена: {worker.email}")
309
+ except Exception as ex:
310
+ self._log(f"Не удалось удалить локально: {ex}")
307
311
 
308
312
  def get_pending_invites(self) -> list[dict]:
309
313
  page = self._ensure_admin_page()
@@ -351,6 +355,7 @@ class SlotManager:
351
355
  # Полный OAuth логин (как при перелогине) — получаем все токены
352
356
  self._log(f"OAuth логин {worker.email}...")
353
357
  page2: Page | None = None
358
+ oauth_ok = False
354
359
  try:
355
360
  page2, session = oauth_login(
356
361
  email=worker.email,
@@ -370,13 +375,19 @@ class SlotManager:
370
375
  worker_dir = self.store.worker_dir / worker.id
371
376
  codex_path = save_codex_file(worker_dir, session, worker.email)
372
377
  self._log(f"Codex-файл: {codex_path.name}")
378
+ oauth_ok = True
379
+ else:
380
+ self._log(f"OAuth: нет access_token для {worker.email}")
373
381
  except Exception as e:
374
382
  self._log(f"OAuth логин не удался: {e}")
375
383
  finally:
376
384
  if page2:
377
385
  close_browser(page2, log=self._log)
378
386
 
379
- self._log(f"Слот {worker.email} зарегистрирован!")
387
+ if oauth_ok:
388
+ self._log(f"Слот {worker.email} готов")
389
+ else:
390
+ self._log(f"Слот {worker.email} зарегистрирован, но OAuth не завершён — нужен перелогин")
380
391
 
381
392
  def get_status(self) -> dict:
382
393
  workers = self._get_workers()