shopping-cli 3.0.0__py3-none-any.whl

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 (82) hide show
  1. shopping_cli/__init__.py +3 -0
  2. shopping_cli/agents/__init__.py +1 -0
  3. shopping_cli/agents/agent_cli.py +31 -0
  4. shopping_cli/agents/buyer_cli.py +163 -0
  5. shopping_cli/agents/merchant_agent.py +263 -0
  6. shopping_cli/agents/merchant_daemon.py +872 -0
  7. shopping_cli/agents/tools.py +488 -0
  8. shopping_cli/api/__init__.py +1 -0
  9. shopping_cli/api/app.py +1353 -0
  10. shopping_cli/api/auth.py +125 -0
  11. shopping_cli/api/fallback_asgi.py +219 -0
  12. shopping_cli/api/handlers/__init__.py +1 -0
  13. shopping_cli/api/handlers/agents.py +277 -0
  14. shopping_cli/api/handlers/audit.py +97 -0
  15. shopping_cli/api/handlers/buyer.py +291 -0
  16. shopping_cli/api/handlers/catalog.py +392 -0
  17. shopping_cli/api/handlers/common.py +76 -0
  18. shopping_cli/api/handlers/conversations.py +172 -0
  19. shopping_cli/api/handlers/erp.py +127 -0
  20. shopping_cli/api/handlers/human_review.py +293 -0
  21. shopping_cli/api/handlers/listings_projection.py +42 -0
  22. shopping_cli/api/handlers/negotiation.py +121 -0
  23. shopping_cli/api/idempotency.py +291 -0
  24. shopping_cli/api/limits.py +59 -0
  25. shopping_cli/api/route_registry.py +105 -0
  26. shopping_cli/api/routes_agents.py +13 -0
  27. shopping_cli/api/routes_conversations.py +13 -0
  28. shopping_cli/api/routes_marketplace.py +13 -0
  29. shopping_cli/api/routes_merchants.py +13 -0
  30. shopping_cli/api/server.py +31 -0
  31. shopping_cli/cli.py +897 -0
  32. shopping_cli/cli_agent_commands.py +386 -0
  33. shopping_cli/cli_buyer_commands.py +231 -0
  34. shopping_cli/cli_catalog_commands.py +281 -0
  35. shopping_cli/cli_common.py +135 -0
  36. shopping_cli/cli_conversation_commands.py +167 -0
  37. shopping_cli/cli_erp_commands.py +60 -0
  38. shopping_cli/cli_listing_commands.py +35 -0
  39. shopping_cli/commerce/__init__.py +6 -0
  40. shopping_cli/commerce/adapters.py +310 -0
  41. shopping_cli/commerce/data_source.py +95 -0
  42. shopping_cli/config.py +262 -0
  43. shopping_cli/contracts/shopping.negotiation/0.1/capabilities.schema.json +47 -0
  44. shopping_cli/contracts/shopping.negotiation/0.1/decision.schema.json +120 -0
  45. shopping_cli/contracts/shopping.negotiation/0.1/policy-result.schema.json +52 -0
  46. shopping_cli/contracts/shopping.negotiation/0.1/snapshot.schema.json +172 -0
  47. shopping_cli/core/__init__.py +1 -0
  48. shopping_cli/core/catalog.py +1281 -0
  49. shopping_cli/core/channels.py +329 -0
  50. shopping_cli/core/conversations.py +502 -0
  51. shopping_cli/core/delivery.py +7 -0
  52. shopping_cli/core/errors.py +43 -0
  53. shopping_cli/core/harness.py +345 -0
  54. shopping_cli/core/limits.py +137 -0
  55. shopping_cli/core/negotiation.py +273 -0
  56. shopping_cli/core/policies.py +356 -0
  57. shopping_cli/core/risk.py +101 -0
  58. shopping_cli/core/tokens.py +33 -0
  59. shopping_cli/data_sources/__init__.py +12 -0
  60. shopping_cli/data_sources/erp_source.py +440 -0
  61. shopping_cli/db/__init__.py +1 -0
  62. shopping_cli/db/migrations.py +415 -0
  63. shopping_cli/db/models.py +325 -0
  64. shopping_cli/db/provenance.py +21 -0
  65. shopping_cli/db/session.py +108 -0
  66. shopping_cli/http_client.py +149 -0
  67. shopping_cli/listings/__init__.py +4 -0
  68. shopping_cli/listings/projection.py +148 -0
  69. shopping_cli/services/__init__.py +1 -0
  70. shopping_cli/services/agents.py +244 -0
  71. shopping_cli/services/audit.py +34 -0
  72. shopping_cli/services/buyer_bootstrap.py +23 -0
  73. shopping_cli/services/conversations.py +209 -0
  74. shopping_cli/services/human_review.py +165 -0
  75. shopping_cli/services/negotiation.py +929 -0
  76. shopping_cli/services/tokens.py +318 -0
  77. shopping_cli-3.0.0.dist-info/METADATA +219 -0
  78. shopping_cli-3.0.0.dist-info/RECORD +82 -0
  79. shopping_cli-3.0.0.dist-info/WHEEL +5 -0
  80. shopping_cli-3.0.0.dist-info/entry_points.txt +4 -0
  81. shopping_cli-3.0.0.dist-info/licenses/LICENSE +201 -0
  82. shopping_cli-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,872 @@
1
+ """Local lifecycle management for resident merchant-agent processes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import hashlib
7
+ import os
8
+ import secrets
9
+ import signal
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any, Callable
15
+ from datetime import datetime
16
+
17
+ try:
18
+ import fcntl
19
+ except ImportError: # pragma: no cover - Windows
20
+ fcntl = None # type: ignore[assignment]
21
+
22
+ try:
23
+ import msvcrt
24
+ except ImportError: # pragma: no cover - POSIX
25
+ msvcrt = None # type: ignore[assignment]
26
+
27
+ try:
28
+ import psutil
29
+ except ImportError: # pragma: no cover - optional runtime dependency
30
+ psutil = None
31
+
32
+ from shopping_cli.agents import merchant_agent
33
+ from shopping_cli.db.session import db_session, decode_json, now_iso
34
+ from shopping_cli.core.limits import safe_non_negative_int, safe_positive_float, safe_non_negative_float_with_max as safe_non_negative_float
35
+
36
+ DEFAULT_STATE_DIR = Path.home() / ".local" / "state" / "shopping-cli"
37
+ MAX_AGENT_INTERVAL_SECONDS = 3600.0
38
+ MAX_AGENT_STOP_TIMEOUT_SECONDS = 300.0
39
+ MAX_AGENT_LOG_TAIL = 1000
40
+ MAX_AGENT_LOG_BYTES = 5 * 1024 * 1024
41
+ MAX_AGENT_LOG_BACKUPS = 3
42
+ MAX_AGENT_ERROR_BACKOFF_SECONDS = 60.0
43
+
44
+
45
+ def state_dir_from(value: str | Path | None = None) -> Path:
46
+ return Path(value or os.environ.get("SHOPPING_CLI_STATE_DIR") or DEFAULT_STATE_DIR).expanduser()
47
+
48
+
49
+ def safe_merchant_id(merchant_id: str) -> str:
50
+ raw = str(merchant_id or "")
51
+ slug = "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "_" for ch in raw).strip("._-")
52
+ slug = (slug or "merchant")[:64]
53
+ digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
54
+ return f"{slug}-{digest}"
55
+
56
+
57
+ def agent_paths(merchant_id: str, state_dir: str | Path | None = None) -> dict[str, Path]:
58
+ root = state_dir_from(state_dir)
59
+ safe_id = safe_merchant_id(merchant_id)
60
+ return {
61
+ "state_dir": root,
62
+ "pid_file": root / "agents" / f"{safe_id}.pid",
63
+ "state_file": root / "agents" / f"{safe_id}.state.json",
64
+ "stop_file": root / "agents" / f"{safe_id}.stop",
65
+ "log_file": root / "logs" / f"{safe_id}.log",
66
+ }
67
+
68
+
69
+ def ensure_agent_dirs(paths: dict[str, Path]) -> None:
70
+ paths["pid_file"].parent.mkdir(parents=True, exist_ok=True)
71
+ paths["state_file"].parent.mkdir(parents=True, exist_ok=True)
72
+ paths["log_file"].parent.mkdir(parents=True, exist_ok=True)
73
+
74
+
75
+ def rotate_agent_log(log_file: Path) -> bool:
76
+ try:
77
+ if log_file.stat().st_size <= MAX_AGENT_LOG_BYTES:
78
+ return False
79
+ except OSError:
80
+ return False
81
+ oldest = log_file.with_suffix(log_file.suffix + f".{MAX_AGENT_LOG_BACKUPS}")
82
+ if oldest.exists():
83
+ oldest.unlink()
84
+ for index in range(MAX_AGENT_LOG_BACKUPS - 1, 0, -1):
85
+ source = log_file.with_suffix(log_file.suffix + f".{index}")
86
+ if source.exists():
87
+ source.replace(log_file.with_suffix(log_file.suffix + f".{index + 1}"))
88
+ log_file.replace(log_file.with_suffix(log_file.suffix + ".1"))
89
+ return True
90
+
91
+
92
+ def _redirect_stdout_to(log_path: Path) -> None:
93
+ """Repoint stdout at a freshly rotated log file.
94
+
95
+ Renaming the live log leaves the daemon writing into the renamed backup,
96
+ so after an in-process rotation stdout must be attached to the new file.
97
+ """
98
+ try:
99
+ target_fd = sys.stdout.fileno()
100
+ except (OSError, ValueError):
101
+ return
102
+ fd = os.open(str(log_path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
103
+ try:
104
+ os.dup2(fd, target_fd)
105
+ finally:
106
+ os.close(fd)
107
+
108
+
109
+ def tail_log_lines(path: Path, count: int, max_bytes: int = 2 * 1024 * 1024) -> list[str]:
110
+ try:
111
+ with path.open("rb") as handle:
112
+ handle.seek(0, os.SEEK_END)
113
+ remaining = handle.tell()
114
+ chunks: list[bytes] = []
115
+ newlines = 0
116
+ read_bytes = 0
117
+ while remaining > 0 and newlines <= count and read_bytes < max_bytes:
118
+ size = min(65536, remaining, max_bytes - read_bytes)
119
+ remaining -= size
120
+ handle.seek(remaining)
121
+ chunk = handle.read(size)
122
+ chunks.append(chunk)
123
+ newlines += chunk.count(b"\n")
124
+ read_bytes += len(chunk)
125
+ return b"".join(reversed(chunks)).decode("utf-8").splitlines()[-count:]
126
+ except (OSError, UnicodeDecodeError):
127
+ return []
128
+
129
+
130
+ def read_json(path: Path, default: Any) -> Any:
131
+ try:
132
+ decoded = json.loads(path.read_text(encoding="utf-8"))
133
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
134
+ return default
135
+ if isinstance(default, dict) and not isinstance(decoded, dict):
136
+ return default
137
+ return decoded
138
+
139
+
140
+
141
+
142
+
143
+ def safe_replied_count(value: Any) -> int:
144
+ if not isinstance(value, list):
145
+ return 0
146
+ return len(value)
147
+
148
+
149
+ def permanent_agent_error(error: str) -> bool:
150
+ lowered = str(error or "").lower()
151
+ return any(marker in lowered for marker in ("invalid authorization", "revoked authorization", "expired authorization", "token required", "unknown merchant"))
152
+
153
+
154
+ def write_json_atomic(path: Path, value: Any) -> None:
155
+ path.parent.mkdir(parents=True, exist_ok=True)
156
+ tmp = path.with_name(f"{path.name}.tmp")
157
+ tmp.write_text(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
158
+ tmp.replace(path)
159
+
160
+
161
+ class _PidFileLock:
162
+ """Advisory exclusive lock on a companion lock file for the PID file.
163
+
164
+ Uses ``fcntl`` on POSIX and ``msvcrt.locking`` on Windows.
165
+ """
166
+
167
+ def __init__(self, pid_file: Path) -> None:
168
+ self.lock_path = Path(pid_file).with_suffix(pid_file.suffix + ".lock")
169
+ self._fd: int | None = None
170
+
171
+ def __enter__(self) -> "_PidFileLock":
172
+ self.lock_path.parent.mkdir(parents=True, exist_ok=True)
173
+ self._fd = os.open(
174
+ str(self.lock_path),
175
+ os.O_RDWR | os.O_CREAT,
176
+ 0o644,
177
+ )
178
+ if fcntl is not None:
179
+ try:
180
+ fcntl.flock(self._fd, fcntl.LOCK_EX)
181
+ except OSError as exc:
182
+ raise RuntimeError(f"Failed to acquire PID file lock ({self.lock_path}): {exc}") from exc
183
+ elif msvcrt is not None: # pragma: no cover - Windows
184
+ try:
185
+ if os.fstat(self._fd).st_size == 0:
186
+ os.write(self._fd, b"0")
187
+ os.lseek(self._fd, 0, os.SEEK_SET)
188
+ msvcrt.locking(self._fd, msvcrt.LK_LOCK, 1)
189
+ except OSError as exc:
190
+ raise RuntimeError(f"Failed to acquire PID file lock ({self.lock_path}): {exc}") from exc
191
+ else: # pragma: no cover - unsupported platform
192
+ raise RuntimeError("No supported PID file locking implementation is available")
193
+ return self
194
+
195
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
196
+ if self._fd is None:
197
+ return
198
+ if fcntl is not None:
199
+ try:
200
+ fcntl.flock(self._fd, fcntl.LOCK_UN)
201
+ except OSError:
202
+ pass
203
+ elif msvcrt is not None: # pragma: no cover - Windows
204
+ try:
205
+ os.lseek(self._fd, 0, os.SEEK_SET)
206
+ msvcrt.locking(self._fd, msvcrt.LK_UNLCK, 1)
207
+ except OSError:
208
+ pass
209
+ try:
210
+ os.close(self._fd)
211
+ except OSError:
212
+ pass
213
+ self._fd = None
214
+
215
+
216
+ def is_process_running(pid: int | None) -> bool:
217
+ if not pid or pid <= 0:
218
+ return False
219
+ if psutil is None:
220
+ return _is_process_running_legacy(pid)
221
+ try:
222
+ if not psutil.pid_exists(pid):
223
+ return False
224
+ return psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
225
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
226
+ return False
227
+
228
+
229
+ def _is_process_running_legacy(pid: int) -> bool:
230
+ """Unix-only fallback when psutil is unavailable."""
231
+ try:
232
+ os.kill(pid, 0)
233
+ except ProcessLookupError:
234
+ return False
235
+ except PermissionError:
236
+ return True
237
+ try:
238
+ status = subprocess.run(
239
+ ["ps", "-o", "stat=", "-p", str(pid)],
240
+ capture_output=True,
241
+ text=True,
242
+ timeout=1,
243
+ )
244
+ except (OSError, subprocess.SubprocessError):
245
+ return True
246
+ state = status.stdout.strip().upper()
247
+ if status.returncode != 0 or not state:
248
+ # os.kill(pid, 0) above proved that a process currently owns the PID.
249
+ # Some sandboxes deny `ps`; cooperative stop remains safe because the
250
+ # fallback never sends a signal without psutil identity verification.
251
+ return True
252
+ return "Z" not in state
253
+
254
+
255
+ def process_identity(pid: int) -> dict[str, Any] | None:
256
+ if psutil is None or not is_process_running(pid):
257
+ return None
258
+ try:
259
+ process = psutil.Process(pid)
260
+ return {
261
+ "create_time": float(process.create_time()),
262
+ "executable": str(process.exe() or ""),
263
+ "cmdline": [str(item) for item in process.cmdline()],
264
+ }
265
+ except (psutil.NoSuchProcess, psutil.AccessDenied, OSError, ValueError):
266
+ return None
267
+
268
+
269
+ def wait_for_process_identity(pid: int, command: list[str], timeout: float = 0.5) -> dict[str, Any] | None:
270
+ """Wait briefly for a newly spawned Python process to finish ``exec``."""
271
+ deadline = time.monotonic() + timeout
272
+ last_identity: dict[str, Any] | None = None
273
+ ready_since: float | None = None
274
+ required = ["shopping_cli.cli", "agent", "run", "--merchant"]
275
+ while time.monotonic() < deadline:
276
+ identity = process_identity(pid)
277
+ if identity is not None:
278
+ last_identity = identity
279
+ cmdline = [str(item) for item in identity.get("cmdline") or []]
280
+ if all(item in cmdline for item in required):
281
+ now = time.monotonic()
282
+ if ready_since is None:
283
+ ready_since = now
284
+ elif now - ready_since >= 0.1:
285
+ return identity
286
+ else:
287
+ ready_since = None
288
+ elif not is_process_running(pid):
289
+ break
290
+ time.sleep(0.01)
291
+ return last_identity
292
+
293
+
294
+ def pid_record_matches_process(pid_record: dict[str, Any], merchant_id: str) -> bool:
295
+ """Verify that a PID record still identifies this exact daemon process."""
296
+ pid = safe_non_negative_int(pid_record.get("pid"))
297
+ launch_token = str(pid_record.get("launch_token") or "")
298
+ recorded_create_time = pid_record.get("create_time", 0.0)
299
+ if not pid or not launch_token or str(pid_record.get("merchant_id") or "") != str(merchant_id):
300
+ return False
301
+ recorded_command = [str(item) for item in (pid_record.get("command") or [])]
302
+ required = ["shopping_cli.cli", "agent", "run", "--merchant", str(merchant_id)]
303
+ if not all(item in recorded_command for item in required):
304
+ return False
305
+ if psutil is None:
306
+ return is_process_running(pid)
307
+ identity = process_identity(pid)
308
+ if identity is None:
309
+ return False
310
+ try:
311
+ if abs(float(recorded_create_time) - float(identity["create_time"])) > 0.01:
312
+ return False
313
+ except (TypeError, ValueError):
314
+ return False
315
+ recorded_executable = str(pid_record.get("executable") or "")
316
+ if recorded_executable and os.path.realpath(recorded_executable) != os.path.realpath(identity["executable"]):
317
+ return False
318
+ cmdline = list(identity.get("cmdline") or [])
319
+ if not all(item in cmdline for item in required):
320
+ return False
321
+ return True
322
+
323
+
324
+ def state_matches_pid_record(state: dict[str, Any], pid_record: dict[str, Any], merchant_id: str) -> bool:
325
+ launch_token = str(pid_record.get("launch_token") or "")
326
+ if not launch_token or str(state.get("launch_token") or "") != launch_token:
327
+ return False
328
+ if str(state.get("merchant_id") or "") != str(merchant_id):
329
+ return False
330
+ state_pid = safe_non_negative_int(state.get("pid"))
331
+ record_pid = safe_non_negative_int(pid_record.get("pid"))
332
+ if state_pid and state_pid != record_pid:
333
+ return False
334
+ try:
335
+ updated_at = datetime.fromisoformat(str(state.get("updated_at") or ""))
336
+ interval = safe_positive_float(pid_record.get("interval"), 3.0, maximum=MAX_AGENT_INTERVAL_SECONDS)
337
+ max_age = max(interval * 3, 60.0)
338
+ if (datetime.now() - updated_at).total_seconds() > max_age:
339
+ return False
340
+ except (TypeError, ValueError):
341
+ return False
342
+ return True
343
+
344
+
345
+ def read_agent_heartbeat(db_path: str | Path, merchant_id: str) -> dict[str, Any]:
346
+ agent_id = f"shopping-cli-merchant-agent:{merchant_id}"
347
+ with db_session(db_path) as conn:
348
+ row = conn.execute("select * from agents where id = ?", (agent_id,)).fetchone()
349
+ if row is None:
350
+ return {
351
+ "id": agent_id,
352
+ "type": "merchant",
353
+ "owner_id": merchant_id,
354
+ "status": "away",
355
+ "capabilities": [],
356
+ "last_seen_at": None,
357
+ }
358
+ return {
359
+ "id": row["id"],
360
+ "type": row["type"],
361
+ "owner_id": row["owner_id"],
362
+ "status": row["status"],
363
+ "capabilities": decode_json(row["capabilities_json"], []),
364
+ "last_seen_at": row["last_seen_at"],
365
+ }
366
+
367
+
368
+ def write_state(
369
+ state_file: Path,
370
+ merchant_id: str,
371
+ running: bool,
372
+ counters: dict[str, int],
373
+ last_error: str | None = None,
374
+ pid: int | None = None,
375
+ extra: dict[str, Any] | None = None,
376
+ ) -> dict[str, Any]:
377
+ state = {
378
+ "merchant_id": merchant_id,
379
+ "running": running,
380
+ "pid": pid,
381
+ "updated_at": now_iso(),
382
+ "counters": counters,
383
+ "last_error": last_error,
384
+ }
385
+ if extra:
386
+ state.update(extra)
387
+ write_json_atomic(state_file, state)
388
+ return state
389
+
390
+
391
+ def start_agent(
392
+ db_path: str | Path,
393
+ merchant_id: str,
394
+ interval: float = 3.0,
395
+ state_dir: str | Path | None = None,
396
+ api_url: str = "",
397
+ agent_token: str = "",
398
+ merchant_token: str = "",
399
+ host: str = "",
400
+ session_id: str = "",
401
+ ) -> dict[str, Any]:
402
+ api_url = str(api_url or "").strip()
403
+ agent_token = str(agent_token or "").strip()
404
+ merchant_token = str(merchant_token or "").strip()
405
+ host = str(host or "").strip()
406
+ session_id = str(session_id or "").strip()
407
+ mode = "api" if api_url else "sqlite"
408
+ if api_url and not (agent_token or merchant_token):
409
+ raise SystemExit("--merchant-token or --agent-token is required with --api-url")
410
+ interval = safe_positive_float(interval, 3.0, maximum=MAX_AGENT_INTERVAL_SECONDS)
411
+
412
+ paths = agent_paths(merchant_id, state_dir)
413
+ ensure_agent_dirs(paths)
414
+
415
+ with _PidFileLock(paths["pid_file"]):
416
+ pid_record = read_json(paths["pid_file"], {})
417
+ existing_pid = safe_non_negative_int(pid_record.get("pid"))
418
+ existing_state = read_json(paths["state_file"], {})
419
+ existing_matches = (
420
+ state_matches_pid_record(existing_state, pid_record, merchant_id)
421
+ and pid_record_matches_process(pid_record, merchant_id)
422
+ )
423
+ stale_replaced = bool(existing_pid and not existing_matches)
424
+ if existing_pid and existing_matches:
425
+ raise SystemExit(f"Agent already running for merchant {merchant_id}: pid {existing_pid}")
426
+ if paths["stop_file"].exists():
427
+ paths["stop_file"].unlink()
428
+
429
+ if not api_url:
430
+ with db_session(db_path) as conn:
431
+ merchant_agent.heartbeat(conn, merchant_id, status="online")
432
+
433
+ rotate_agent_log(paths["log_file"])
434
+
435
+ repo_root = Path(__file__).resolve().parents[2]
436
+ command = [
437
+ sys.executable,
438
+ "-m",
439
+ "shopping_cli.cli",
440
+ "--db",
441
+ str(Path(db_path).expanduser()),
442
+ "agent",
443
+ "run",
444
+ "--merchant",
445
+ merchant_id,
446
+ "--interval",
447
+ str(interval),
448
+ "--format",
449
+ "json",
450
+ "--state-file",
451
+ str(paths["state_file"]),
452
+ "--stop-file",
453
+ str(paths["stop_file"]),
454
+ ]
455
+ if host:
456
+ command.extend(["--host", host])
457
+ if session_id:
458
+ command.extend(["--session-id", session_id])
459
+ env = os.environ.copy()
460
+ launch_token = secrets.token_urlsafe(24)
461
+ env["SHOPPING_CLI_STATE_DIR"] = str(paths["state_dir"])
462
+ env["SHOPPING_AGENT_LAUNCH_TOKEN"] = launch_token
463
+ if api_url:
464
+ env["SHOPPING_MARKETPLACE_API_URL"] = api_url
465
+ if host:
466
+ env["SHOPPING_AGENT_HOST"] = host
467
+ if session_id:
468
+ env["SHOPPING_AGENT_SESSION_ID"] = session_id
469
+ if agent_token:
470
+ env["SHOPPING_AGENT_TOKEN"] = agent_token
471
+ env.pop("SHOPPING_MERCHANT_TOKEN", None)
472
+ elif merchant_token:
473
+ env["SHOPPING_MERCHANT_TOKEN"] = merchant_token
474
+ env.pop("SHOPPING_AGENT_TOKEN", None)
475
+ with paths["log_file"].open("ab", buffering=0) as log:
476
+ process = subprocess.Popen(
477
+ command,
478
+ stdin=subprocess.DEVNULL,
479
+ stdout=log,
480
+ stderr=subprocess.STDOUT,
481
+ cwd=str(repo_root),
482
+ env=env,
483
+ start_new_session=True,
484
+ )
485
+
486
+ started_at = now_iso()
487
+ identity = wait_for_process_identity(process.pid, command) or {
488
+ "create_time": 0.0,
489
+ "executable": "",
490
+ "cmdline": command,
491
+ }
492
+ pid_payload = {
493
+ "pid": process.pid,
494
+ "create_time": identity["create_time"],
495
+ "executable": identity["executable"],
496
+ "cmdline": identity["cmdline"],
497
+ "launch_token": launch_token,
498
+ "merchant_id": merchant_id,
499
+ "db_path": str(Path(db_path).expanduser()),
500
+ "interval": interval,
501
+ "mode": mode,
502
+ "api_url": api_url,
503
+ "host": host,
504
+ "session_id": session_id,
505
+ "started_at": started_at,
506
+ "command": command,
507
+ "log_file": str(paths["log_file"]),
508
+ "state_file": str(paths["state_file"]),
509
+ "stop_file": str(paths["stop_file"]),
510
+ }
511
+ write_json_atomic(paths["pid_file"], pid_payload)
512
+
513
+ write_state(
514
+ paths["state_file"],
515
+ merchant_id,
516
+ running=True,
517
+ counters={"checked": 0, "replied": 0},
518
+ pid=process.pid,
519
+ extra={
520
+ "started_at": started_at,
521
+ "mode": mode,
522
+ "api_url": api_url,
523
+ "host": host,
524
+ "session_id": session_id,
525
+ "launch_token": launch_token,
526
+ },
527
+ )
528
+ return {
529
+ "ok": True,
530
+ "merchant_id": merchant_id,
531
+ "pid": process.pid,
532
+ "running": True,
533
+ "mode": mode,
534
+ "api_url": api_url,
535
+ "host": host,
536
+ "session_id": session_id,
537
+ "stale_replaced": stale_replaced,
538
+ "pid_file": str(paths["pid_file"]),
539
+ "state_file": str(paths["state_file"]),
540
+ "stop_file": str(paths["stop_file"]),
541
+ "log_file": str(paths["log_file"]),
542
+ }
543
+
544
+
545
+ def stop_agent(
546
+ db_path: str | Path,
547
+ merchant_id: str,
548
+ state_dir: str | Path | None = None,
549
+ timeout: float = 5.0,
550
+ ) -> dict[str, Any]:
551
+ timeout = safe_non_negative_float(timeout, 5.0, maximum=MAX_AGENT_STOP_TIMEOUT_SECONDS)
552
+ paths = agent_paths(merchant_id, state_dir)
553
+ with _PidFileLock(paths["pid_file"]):
554
+ pid_record = read_json(paths["pid_file"], {})
555
+ pid = safe_non_negative_int(pid_record.get("pid"))
556
+ mode = str(pid_record.get("mode") or "sqlite")
557
+ host = str(pid_record.get("host") or "")
558
+ session_id = str(pid_record.get("session_id") or "")
559
+ api_url = str(pid_record.get("api_url") or "")
560
+ launch_token = str(pid_record.get("launch_token") or "")
561
+ state_before_stop = read_json(paths["state_file"], {})
562
+ state_launch_token = str(state_before_stop.get("launch_token") or "")
563
+ was_running = bool(
564
+ launch_token
565
+ and state_launch_token == launch_token
566
+ and state_matches_pid_record(state_before_stop, pid_record, merchant_id)
567
+ and pid_record_matches_process(pid_record, merchant_id)
568
+ )
569
+
570
+ if was_running:
571
+ paths["stop_file"].parent.mkdir(parents=True, exist_ok=True)
572
+ paths["stop_file"].write_text(launch_token, encoding="utf-8")
573
+ if psutil is not None:
574
+ try:
575
+ os.kill(pid, signal.SIGTERM)
576
+ except PermissionError:
577
+ pass
578
+ deadline = time.time() + timeout
579
+ while time.time() < deadline:
580
+ state = read_json(paths["state_file"], {})
581
+ if state.get("running") is False or not pid_record_matches_process(pid_record, merchant_id):
582
+ break
583
+ time.sleep(0.1)
584
+ # SIGKILL 兜底:SIGTERM 被阻塞的处理中进程(如卡在 HTTP 调用,
585
+ # 默认 5s 内无法响应)停不掉——强杀并短等待。
586
+ if pid_record_matches_process(pid_record, merchant_id):
587
+ try:
588
+ os.kill(pid, signal.SIGKILL)
589
+ except (PermissionError, ProcessLookupError):
590
+ pass
591
+ time.sleep(0.2)
592
+
593
+ with _PidFileLock(paths["pid_file"]):
594
+ state_after_stop = read_json(paths["state_file"], {})
595
+ current_record = read_json(paths["pid_file"], {})
596
+ same_launch = bool(
597
+ launch_token
598
+ and str(current_record.get("launch_token") or "") == launch_token
599
+ and safe_non_negative_int(current_record.get("pid")) == pid
600
+ )
601
+ # A different launch token means a new daemon took over the pid/state
602
+ # files while this stop was in flight; it must remain untouched.
603
+ replaced_by_new_launch = bool(str(current_record.get("launch_token") or "")) and not same_launch
604
+ running = same_launch and pid_record_matches_process(current_record, merchant_id) and state_after_stop.get("running") is not False
605
+ if not running and same_launch and paths["pid_file"].exists():
606
+ paths["pid_file"].unlink()
607
+
608
+ if mode != "api":
609
+ with db_session(db_path) as conn:
610
+ merchant_agent.heartbeat(conn, merchant_id, status="away")
611
+
612
+ previous = read_json(paths["state_file"], {})
613
+ counters = previous.get("counters") or {"checked": 0, "replied": 0}
614
+ if not running and not replaced_by_new_launch:
615
+ write_state(
616
+ paths["state_file"],
617
+ merchant_id,
618
+ running=False,
619
+ counters=counters,
620
+ last_error=previous.get("last_error"),
621
+ pid=pid or None,
622
+ extra={
623
+ "stopped_at": now_iso(),
624
+ "stop_timed_out": running,
625
+ "mode": mode,
626
+ "api_url": api_url,
627
+ "host": host,
628
+ "session_id": session_id,
629
+ "launch_token": launch_token,
630
+ },
631
+ )
632
+ return {
633
+ "ok": not running,
634
+ "merchant_id": merchant_id,
635
+ "pid": pid or None,
636
+ "mode": mode,
637
+ "api_url": api_url,
638
+ "host": host,
639
+ "session_id": session_id,
640
+ "was_running": was_running,
641
+ "running": running,
642
+ "pid_file": str(paths["pid_file"]),
643
+ "state_file": str(paths["state_file"]),
644
+ "stop_file": str(paths["stop_file"]),
645
+ "log_file": str(paths["log_file"]),
646
+ }
647
+
648
+
649
+ def status_agent(db_path: str | Path, merchant_id: str, state_dir: str | Path | None = None) -> dict[str, Any]:
650
+ paths = agent_paths(merchant_id, state_dir)
651
+ with _PidFileLock(paths["pid_file"]):
652
+ pid_record = read_json(paths["pid_file"], {})
653
+ pid = safe_non_negative_int(pid_record.get("pid"))
654
+ state = read_json(paths["state_file"], {})
655
+ mode = str(pid_record.get("mode") or state.get("mode") or "sqlite")
656
+ host = str(pid_record.get("host") or state.get("host") or "")
657
+ session_id = str(pid_record.get("session_id") or state.get("session_id") or "")
658
+ launch_token = str(pid_record.get("launch_token") or "")
659
+ running = bool(
660
+ launch_token
661
+ and state_matches_pid_record(state, pid_record, merchant_id)
662
+ and pid_record_matches_process(pid_record, merchant_id)
663
+ and state.get("running") is not False
664
+ )
665
+ counters = state.get("counters") or {"checked": 0, "replied": 0}
666
+ return {
667
+ "ok": True,
668
+ "merchant_id": merchant_id,
669
+ "pid": pid or None,
670
+ "mode": mode,
671
+ "api_url": str(pid_record.get("api_url") or state.get("api_url") or ""),
672
+ "host": host,
673
+ "session_id": session_id,
674
+ "running": running,
675
+ "stale_pid": bool(pid and not running),
676
+ "pid_file": str(paths["pid_file"]),
677
+ "state_file": str(paths["state_file"]),
678
+ "stop_file": str(paths["stop_file"]),
679
+ "log_file": str(paths["log_file"]),
680
+ "heartbeat": read_agent_heartbeat(db_path, merchant_id),
681
+ "counters": {
682
+ "checked": safe_non_negative_int(counters.get("checked")),
683
+ "replied": safe_non_negative_int(counters.get("replied")),
684
+ },
685
+ "last_error": state.get("last_error"),
686
+ "started_at": pid_record.get("started_at") or state.get("started_at"),
687
+ "updated_at": state.get("updated_at"),
688
+ }
689
+
690
+
691
+ def logs_agent(merchant_id: str, tail: int = 20, state_dir: str | Path | None = None) -> dict[str, Any]:
692
+ if tail <= 0:
693
+ raise ValueError("tail must be greater than 0")
694
+ tail = min(tail, MAX_AGENT_LOG_TAIL)
695
+ paths = agent_paths(merchant_id, state_dir)
696
+ entries: list[dict[str, Any]] = []
697
+ raw_lines: list[str] = []
698
+ raw_lines = tail_log_lines(paths["log_file"], tail)
699
+ for line in raw_lines:
700
+ try:
701
+ parsed = json.loads(line)
702
+ except json.JSONDecodeError:
703
+ parsed = {"event": "raw", "text": line}
704
+ if not isinstance(parsed, dict):
705
+ parsed = {"event": "raw", "text": line}
706
+ entries.append(parsed)
707
+ return {"ok": True, "merchant_id": merchant_id, "log_file": str(paths["log_file"]), "entries": entries}
708
+
709
+
710
+ def _run_process_loop(
711
+ merchant_id: str,
712
+ process_once: Callable[[], dict[str, Any]],
713
+ mark_away: Callable[[], Any],
714
+ interval: float = 3.0,
715
+ state_file: str | Path | None = None,
716
+ stop_file: str | Path | None = None,
717
+ state_extra: dict[str, Any] | None = None,
718
+ log_file: str | Path | None = None,
719
+ ) -> None:
720
+ stop_requested = False
721
+ counters = {"checked": 0, "replied": 0}
722
+ last_error: str | None = None
723
+ state_path = Path(state_file).expanduser() if state_file else None
724
+ stop_path = Path(stop_file).expanduser() if stop_file else None
725
+ log_path = Path(log_file).expanduser() if log_file else None
726
+ interval = safe_positive_float(interval, 3.0, maximum=MAX_AGENT_INTERVAL_SECONDS)
727
+ error_streak = 0
728
+
729
+ def request_stop(_signum: int, _frame: Any) -> None:
730
+ nonlocal stop_requested
731
+ stop_requested = True
732
+
733
+ previous_term = signal.getsignal(signal.SIGTERM)
734
+ previous_int = signal.getsignal(signal.SIGINT)
735
+ signal.signal(signal.SIGTERM, request_stop)
736
+ signal.signal(signal.SIGINT, request_stop)
737
+ try:
738
+ while not stop_requested and not (stop_path and stop_path.exists()):
739
+ # Rotate while running, not only before start; otherwise a
740
+ # long-lived daemon grows its log file without bound.
741
+ if log_path is not None and rotate_agent_log(log_path):
742
+ _redirect_stdout_to(log_path)
743
+ try:
744
+ result = process_once()
745
+ checked = safe_non_negative_int(result.get("checked"))
746
+ replied_count = safe_replied_count(result.get("replied"))
747
+ counters["checked"] += checked
748
+ counters["replied"] += replied_count
749
+ last_error = None
750
+ error_streak = 0
751
+ event = {
752
+ "event": "process_once",
753
+ "at": now_iso(),
754
+ "merchant_id": merchant_id,
755
+ "checked": checked,
756
+ "replied_count": replied_count,
757
+ "counters": counters,
758
+ "result": result,
759
+ }
760
+ except Exception as exc: # pragma: no cover - defensive runtime path
761
+ error_streak += 1
762
+ last_error = f"{type(exc).__name__}: {exc}"
763
+ fatal = error_streak >= 3 and permanent_agent_error(last_error)
764
+ if fatal:
765
+ stop_requested = True
766
+ event = {
767
+ "event": "error",
768
+ "at": now_iso(),
769
+ "merchant_id": merchant_id,
770
+ "counters": counters,
771
+ "error": last_error,
772
+ "error_streak": error_streak,
773
+ "fatal": fatal,
774
+ }
775
+ print(json.dumps(event, ensure_ascii=False, sort_keys=True, default=str), flush=True)
776
+ if state_path:
777
+ write_state(
778
+ state_path,
779
+ merchant_id,
780
+ running=True,
781
+ counters=counters,
782
+ last_error=last_error,
783
+ pid=os.getpid(),
784
+ extra=state_extra,
785
+ )
786
+
787
+ retry_interval = min(interval * (2 ** min(error_streak, 6)), MAX_AGENT_ERROR_BACKOFF_SECONDS)
788
+ deadline = time.time() + max(retry_interval, 0.05)
789
+ while not stop_requested and not (stop_path and stop_path.exists()) and time.time() < deadline:
790
+ time.sleep(min(0.1, max(deadline - time.time(), 0.01)))
791
+ finally:
792
+ try:
793
+ mark_away()
794
+ finally:
795
+ if state_path:
796
+ stopped_extra = dict(state_extra or {})
797
+ stopped_extra["stopped_at"] = now_iso()
798
+ write_state(
799
+ state_path,
800
+ merchant_id,
801
+ running=False,
802
+ counters=counters,
803
+ last_error=last_error,
804
+ pid=os.getpid(),
805
+ extra=stopped_extra,
806
+ )
807
+ if stop_path and stop_path.exists():
808
+ stop_path.unlink()
809
+ signal.signal(signal.SIGTERM, previous_term)
810
+ signal.signal(signal.SIGINT, previous_int)
811
+
812
+
813
+ def run_forever(
814
+ db_path: str | Path,
815
+ merchant_id: str,
816
+ interval: float = 3.0,
817
+ state_file: str | Path | None = None,
818
+ stop_file: str | Path | None = None,
819
+ ) -> None:
820
+ def process_once() -> dict[str, Any]:
821
+ with db_session(db_path) as conn:
822
+ return merchant_agent.process_once(conn, merchant_id)
823
+
824
+ def mark_away() -> None:
825
+ with db_session(db_path) as conn:
826
+ merchant_agent.heartbeat(conn, merchant_id, status="away")
827
+
828
+ _run_process_loop(
829
+ merchant_id,
830
+ process_once,
831
+ mark_away,
832
+ interval=interval,
833
+ state_file=state_file,
834
+ stop_file=stop_file,
835
+ state_extra={
836
+ "mode": "sqlite",
837
+ "api_url": "",
838
+ "launch_token": str(os.environ.get("SHOPPING_AGENT_LAUNCH_TOKEN") or ""),
839
+ },
840
+ log_file=agent_paths(merchant_id)["log_file"],
841
+ )
842
+
843
+
844
+ def run_tools_forever(
845
+ tools: Any,
846
+ merchant_id: str,
847
+ interval: float = 3.0,
848
+ state_file: str | Path | None = None,
849
+ stop_file: str | Path | None = None,
850
+ ) -> None:
851
+ def process_once() -> dict[str, Any]:
852
+ return merchant_agent.process_once_with_tools(tools, merchant_id)
853
+
854
+ def mark_away() -> None:
855
+ tools.heartbeat(merchant_id, status="away")
856
+
857
+ _run_process_loop(
858
+ merchant_id,
859
+ process_once,
860
+ mark_away,
861
+ interval=interval,
862
+ state_file=state_file,
863
+ stop_file=stop_file,
864
+ state_extra={
865
+ "mode": "api",
866
+ "api_url": str(getattr(tools, "base_url", "") or ""),
867
+ "host": str(getattr(tools, "host", "") or ""),
868
+ "session_id": str(getattr(tools, "session_id", "") or ""),
869
+ "launch_token": str(os.environ.get("SHOPPING_AGENT_LAUNCH_TOKEN") or ""),
870
+ },
871
+ log_file=agent_paths(merchant_id)["log_file"],
872
+ )