runwhere 0.1.0__tar.gz

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 (37) hide show
  1. runwhere-0.1.0/PKG-INFO +66 -0
  2. runwhere-0.1.0/README.md +33 -0
  3. runwhere-0.1.0/pyproject.toml +55 -0
  4. runwhere-0.1.0/src/runw/__init__.py +3 -0
  5. runwhere-0.1.0/src/runw/__main__.py +5 -0
  6. runwhere-0.1.0/src/runw/cli/__init__.py +0 -0
  7. runwhere-0.1.0/src/runw/cli/auth.py +64 -0
  8. runwhere-0.1.0/src/runw/cli/explain.py +159 -0
  9. runwhere-0.1.0/src/runw/cli/jobs.py +279 -0
  10. runwhere-0.1.0/src/runw/cli/logs.py +103 -0
  11. runwhere-0.1.0/src/runw/cli/main.py +45 -0
  12. runwhere-0.1.0/src/runw/cli/price.py +170 -0
  13. runwhere-0.1.0/src/runw/cli/resume.py +33 -0
  14. runwhere-0.1.0/src/runw/cli/submit.py +366 -0
  15. runwhere-0.1.0/src/runw/cli/sync.py +360 -0
  16. runwhere-0.1.0/src/runw/client/__init__.py +0 -0
  17. runwhere-0.1.0/src/runw/client/api_client.py +182 -0
  18. runwhere-0.1.0/src/runw/client/base.py +122 -0
  19. runwhere-0.1.0/src/runw/client/pricing_client.py +167 -0
  20. runwhere-0.1.0/src/runw/client/ws_client.py +41 -0
  21. runwhere-0.1.0/src/runw/config/__init__.py +0 -0
  22. runwhere-0.1.0/src/runw/config/constants.py +22 -0
  23. runwhere-0.1.0/src/runw/config/store.py +42 -0
  24. runwhere-0.1.0/src/runw/models/__init__.py +0 -0
  25. runwhere-0.1.0/src/runw/models/auth.py +10 -0
  26. runwhere-0.1.0/src/runw/models/sync.py +31 -0
  27. runwhere-0.1.0/src/runw/models/yaml_schema.py +82 -0
  28. runwhere-0.1.0/src/runw/output/__init__.py +0 -0
  29. runwhere-0.1.0/src/runw/output/console.py +94 -0
  30. runwhere-0.1.0/src/runw/sync/__init__.py +0 -0
  31. runwhere-0.1.0/src/runw/sync/compat.py +100 -0
  32. runwhere-0.1.0/src/runw/sync/conda_export.py +357 -0
  33. runwhere-0.1.0/src/runw/sync/env_detector.py +102 -0
  34. runwhere-0.1.0/src/runw/sync/ignore.py +39 -0
  35. runwhere-0.1.0/src/runw/sync/model_detector.py +65 -0
  36. runwhere-0.1.0/src/runw/sync/scanner.py +97 -0
  37. runwhere-0.1.0/src/runw/sync/uploader.py +111 -0
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: runwhere
3
+ Version: 0.1.0
4
+ Summary: RunWhere.ai CLI — GPU job management tool
5
+ License: MIT
6
+ Keywords: gpu,ai,cloud,cli
7
+ Author: RunWhere.ai
8
+ Author-email: dev@runwhere.ai
9
+ Requires-Python: >=3.11
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Provides-Extra: dev
19
+ Requires-Dist: click (>=8.0)
20
+ Requires-Dist: httpx (>=0.27)
21
+ Requires-Dist: pathspec (>=0.12)
22
+ Requires-Dist: pydantic (>=2.0)
23
+ Requires-Dist: pytest (>=8.0) ; extra == "dev"
24
+ Requires-Dist: pytest-mock ; extra == "dev"
25
+ Requires-Dist: pyyaml (>=6.0)
26
+ Requires-Dist: rich (>=13.0)
27
+ Requires-Dist: tomli-w (>=1.0)
28
+ Requires-Dist: websockets (>=12.0)
29
+ Project-URL: Homepage, https://runwhere.ai
30
+ Project-URL: Repository, https://github.com/runwhere/runw
31
+ Description-Content-Type: text/markdown
32
+
33
+ # runw — RunWhere.ai CLI
34
+
35
+ GPU 任务管理工具。通过 HTTP API 管理云端 GPU 资源和 AI 计算任务。
36
+
37
+ ## 安装
38
+
39
+ ```bash
40
+ pip install -e ".[dev]"
41
+ ```
42
+
43
+ ## 使用
44
+
45
+ ```bash
46
+ runw login --endpoint https://api.runwhere.ai
47
+ runw sync -f train.yaml
48
+ runw submit -f train.yaml
49
+ runw logs <job-name> -f
50
+ runw price summary
51
+ runw jobs get
52
+ runw usage
53
+ ```
54
+
55
+ ## 开发
56
+
57
+ ```bash
58
+ pytest tests/ -v
59
+ ```
60
+
61
+ ## 构建
62
+
63
+ ```bash
64
+ bash scripts/build.sh
65
+ ```
66
+
@@ -0,0 +1,33 @@
1
+ # runw — RunWhere.ai CLI
2
+
3
+ GPU 任务管理工具。通过 HTTP API 管理云端 GPU 资源和 AI 计算任务。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ pip install -e ".[dev]"
9
+ ```
10
+
11
+ ## 使用
12
+
13
+ ```bash
14
+ runw login --endpoint https://api.runwhere.ai
15
+ runw sync -f train.yaml
16
+ runw submit -f train.yaml
17
+ runw logs <job-name> -f
18
+ runw price summary
19
+ runw jobs get
20
+ runw usage
21
+ ```
22
+
23
+ ## 开发
24
+
25
+ ```bash
26
+ pytest tests/ -v
27
+ ```
28
+
29
+ ## 构建
30
+
31
+ ```bash
32
+ bash scripts/build.sh
33
+ ```
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["poetry-core>=1.0.0"]
3
+ build-backend = "poetry.core.masonry.api"
4
+
5
+ [project]
6
+ name = "runwhere"
7
+ version = "0.1.0"
8
+ description = "RunWhere.ai CLI — GPU job management tool"
9
+ authors = [
10
+ {name = "RunWhere.ai", email = "dev@runwhere.ai"}
11
+ ]
12
+ license = {text = "MIT"}
13
+ readme = {file = "README.md", content-type = "text/markdown"}
14
+ keywords = ["gpu", "ai", "cloud", "cli"]
15
+ requires-python = ">=3.11"
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Science/Research",
19
+ "Intended Audience :: Developers",
20
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ ]
26
+ dependencies = [
27
+ "click>=8.0",
28
+ "httpx>=0.27",
29
+ "rich>=13.0",
30
+ "pydantic>=2.0",
31
+ "tomli-w>=1.0",
32
+ "websockets>=12.0",
33
+ "pyyaml>=6.0",
34
+ "pathspec>=0.12",
35
+ ]
36
+
37
+ [project.optional-dependencies]
38
+ dev = [
39
+ "pytest>=8.0",
40
+ "pytest-mock",
41
+ ]
42
+
43
+ [project.urls]
44
+ Homepage = "https://runwhere.ai"
45
+ Repository = "https://github.com/runwhere/runw"
46
+
47
+ [project.scripts]
48
+ runw = "runw.cli.main:main"
49
+
50
+ [tool.poetry]
51
+ packages = [{include = "runw", from = "src"}]
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
55
+ addopts = "-v"
@@ -0,0 +1,3 @@
1
+ """RunWhere.ai CLI — GPU job management tool."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m runw`."""
2
+
3
+ from runw.cli.main import main
4
+
5
+ main()
File without changes
@@ -0,0 +1,64 @@
1
+ """Auth commands: login, whoami."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+
9
+ from runw.client.base import BaseClient
10
+ from runw.config.store import ConfigStore
11
+ from runw.output import console
12
+
13
+
14
+ @click.command()
15
+ @click.option("--endpoint", required=True, help="API 服务地址")
16
+ @click.pass_context
17
+ def login(ctx: click.Context, endpoint: str) -> None:
18
+ """登录 RunWhere.ai"""
19
+ json_mode: bool = ctx.obj["json_mode"]
20
+
21
+ # 交互式:隐藏输入提示粘贴 API Token。
22
+ # 非交互(管道/CI,如 `echo $TOKEN | runw login`):从 stdin 读,
23
+ # 避免 hide_input 走 getpass 读 TTY,在无终端时永久挂起。
24
+ if sys.stdin.isatty():
25
+ token = click.prompt("Token", hide_input=True)
26
+ else:
27
+ token = sys.stdin.read().strip()
28
+ if not token:
29
+ console.fatal(
30
+ "未提供 API Token(stdin 为空)",
31
+ suggestion="交互式直接运行 runw login,或 `echo <token> | runw login --endpoint <url>`",
32
+ )
33
+
34
+ store = ConfigStore()
35
+ store.save(endpoint=endpoint, token=token)
36
+
37
+ console.success(f"登录成功(endpoint: {endpoint})", json_mode=json_mode)
38
+
39
+
40
+ @click.command()
41
+ @click.pass_context
42
+ def whoami(ctx: click.Context) -> None:
43
+ """查看当前登录用户"""
44
+ json_mode: bool = ctx.obj["json_mode"]
45
+
46
+ store = ConfigStore()
47
+ if not store.token:
48
+ console.fatal_server("未登录", suggestion="请先执行 runw login --endpoint <URL>")
49
+
50
+ client = BaseClient(store)
51
+ data = client.get("/api/v1/auth/me")
52
+
53
+ # /auth/me 返回 UserInfo:username 可能为空,身份回退到 phone;
54
+ # org 字段实际叫 current_org_id(旧代码取 "org" 永远空)。
55
+ user = data.get("username") or data.get("phone") or f"user#{data.get('id', '')}"
56
+ # PRD §1:whoami 输出「当前用户 + workspace 地址」。workspace = 登录 endpoint。
57
+ console.output(
58
+ {
59
+ "user": user,
60
+ "org_id": data.get("current_org_id", ""),
61
+ "workspace": store.endpoint or "",
62
+ },
63
+ json_mode=json_mode,
64
+ )
@@ -0,0 +1,159 @@
1
+ """--explain 实现:列出平台自动填充的默认值与来源(spec 027 FR-011 / SC-005)。
2
+
3
+ 设计:CLI 端预演平台填充规则(与 backend job_orchestrator.fill_defaults 对齐),
4
+ 给用户在提交前完整透明的可解释视图。
5
+
6
+ 输出条目格式:
7
+ - name: 字段路径(如 environment.command)
8
+ - value: 填充后的值
9
+ - source: "user_yaml" / "platform_default" / "auto_pricing" / "computed"
10
+ - reason: 为什么填这个值(人类可读)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+
19
+ @dataclass
20
+ class FillEntry:
21
+ name: str
22
+ value: Any
23
+ source: str # user_yaml / platform_default / auto_pricing / computed
24
+ reason: str
25
+
26
+
27
+ # 平台默认填充规则——与 backend manifest_builder.build_platform_env_vars 对齐
28
+ # 当用户 YAML 未指定时填充
29
+ DEFAULT_RULES: list[dict[str, Any]] = [
30
+ {
31
+ "name": "kind",
32
+ "default": "Training",
33
+ "reason": "未指定 kind 时默认 Training(spec PRD-runw §6)",
34
+ },
35
+ {
36
+ "name": "resources.gpuNum",
37
+ "default": 1,
38
+ "reason": "未指定 gpuNum 时默认 1 卡",
39
+ },
40
+ {
41
+ "name": "environment.command",
42
+ "default": "python main.py",
43
+ "reason": "未指定 command 时默认 `python main.py`(用户应在 YAML 显式指定)",
44
+ },
45
+ ]
46
+
47
+
48
+ # 平台自动注入的 env(spec PRD-workload §6 + spec 031 Wandb)
49
+ PLATFORM_INJECTED_ENVS: list[dict[str, str]] = [
50
+ {
51
+ "name": "RUNW_JOB_ID",
52
+ "reason": "由 backend 生成,注入到 Pod 用于关联日志/指标",
53
+ },
54
+ {
55
+ "name": "RUNW_JOB_NAME",
56
+ "reason": "用户 YAML 中 job.name",
57
+ },
58
+ {
59
+ "name": "RUNW_ORG_ID",
60
+ "reason": "当前 token 的 org",
61
+ },
62
+ {
63
+ "name": "RUNW_USER_ID",
64
+ "reason": "当前用户",
65
+ },
66
+ {
67
+ "name": "RUNW_NPROC_PER_NODE",
68
+ "reason": "Training 任务自动设为 gpuNum(torch.distributed 用)",
69
+ },
70
+ {
71
+ "name": "NCCL_TIMEOUT=1800",
72
+ "reason": "防止 NCCL 通信超时崩溃(30 分钟)",
73
+ },
74
+ {
75
+ "name": "TORCH_NCCL_ASYNC_ERROR_HANDLING=1",
76
+ "reason": "PyTorch NCCL 异常异步处理",
77
+ },
78
+ {
79
+ "name": "NCCL_DEBUG=WARN",
80
+ "reason": "NCCL 调试输出限制为 WARN 级别",
81
+ },
82
+ {
83
+ "name": "WANDB_BASE_URL / WANDB_API_KEY / WANDB_ENTITY / WANDB_PROJECT",
84
+ "reason": "spec 031 — Org 注册时自动建 Wandb 账号,submit 时注入;YAML 设 environment.wandb_external=true 可禁用",
85
+ },
86
+ ]
87
+
88
+
89
+ def compute_fill_entries(spec_dict: dict[str, Any]) -> list[FillEntry]:
90
+ """对照 DEFAULT_RULES 计算用户 YAML 哪些字段是平台填充的。"""
91
+ entries: list[FillEntry] = []
92
+
93
+ for rule in DEFAULT_RULES:
94
+ name = rule["name"]
95
+ path = name.split(".")
96
+ cur: Any = spec_dict
97
+ present = True
98
+ for p in path:
99
+ if not isinstance(cur, dict) or p not in cur or cur[p] is None:
100
+ present = False
101
+ break
102
+ cur = cur[p]
103
+
104
+ if present:
105
+ entries.append(
106
+ FillEntry(
107
+ name=name, value=cur, source="user_yaml",
108
+ reason="用户 YAML 显式指定",
109
+ )
110
+ )
111
+ else:
112
+ entries.append(
113
+ FillEntry(
114
+ name=name, value=rule["default"],
115
+ source="platform_default", reason=rule["reason"],
116
+ )
117
+ )
118
+ return entries
119
+
120
+
121
+ def render_explain(
122
+ spec_dict: dict[str, Any],
123
+ *,
124
+ json_mode: bool = False,
125
+ ) -> dict[str, Any] | None:
126
+ """渲染 --explain 输出。json_mode=True 返回 dict 供 console.output;
127
+ 否则直接打印到 stderr 并返回 None。
128
+ """
129
+ entries = compute_fill_entries(spec_dict)
130
+
131
+ payload: dict[str, Any] = {
132
+ "explain": {
133
+ "fields": [
134
+ {
135
+ "name": e.name,
136
+ "value": e.value,
137
+ "source": e.source,
138
+ "reason": e.reason,
139
+ }
140
+ for e in entries
141
+ ],
142
+ "platform_envs": PLATFORM_INJECTED_ENVS,
143
+ }
144
+ }
145
+
146
+ if json_mode:
147
+ return payload
148
+
149
+ from runw.output import console
150
+
151
+ console.info("─── 平台默认值填充过程(--explain)───")
152
+ for e in entries:
153
+ marker = "·" if e.source == "user_yaml" else "+"
154
+ console.info(f" {marker} {e.name} = {e.value} [{e.source}] — {e.reason}")
155
+ console.info("─── 自动注入的环境变量 ───")
156
+ for env in PLATFORM_INJECTED_ENVS:
157
+ console.info(f" + {env['name']} — {env['reason']}")
158
+ console.info("───────────────────────────────")
159
+ return None
@@ -0,0 +1,279 @@
1
+ """作业管理命令(PRD 对齐:动词在前)。
2
+
3
+ PRD §7/§8/§11 命令形:
4
+ runw get jobs [--kind] [--status] 列出作业
5
+ runw get usage [--from] [--to] 用量与费用
6
+ runw describe job <name> 作业详情
7
+ runw stop job <name> 停止作业(保留 checkpoint,释放 GPU)
8
+ runw delete job <name> [--force] 删除作业记录
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import click
14
+
15
+ from runw.client.api_client import ApiClient
16
+ from runw.config.store import ConfigStore
17
+ from runw.output import console
18
+
19
+
20
+ def _format_duration(seconds: int | float) -> str:
21
+ if not seconds:
22
+ return "-"
23
+ s = int(seconds)
24
+ if s < 60:
25
+ return f"{s}s"
26
+ if s < 3600:
27
+ return f"{s // 60}m {s % 60}s"
28
+ h = s // 3600
29
+ m = (s % 3600) // 60
30
+ return f"{h}h {m}m"
31
+
32
+
33
+ def _fnum(value: object, prec: int = 1) -> str:
34
+ """数值格式化(容错)。后端 JSON 把 Decimal 序列化成**字符串**(如 "0.0000"),
35
+ 直接 `:.1f` 会 ValueError 崩 → `runw get jobs`/`get usage` 整条命令挂。先 float 兜底。"""
36
+ try:
37
+ return f"{float(value):,.{prec}f}" # type: ignore[arg-type]
38
+ except (TypeError, ValueError):
39
+ return str(value)
40
+
41
+
42
+ # 配额资源类型 → 展示名(与后端 quota_service.PLAN_QUOTAS 的 key 对齐)
43
+ _QUOTA_LABELS = {
44
+ "inference": "推理服务",
45
+ "notebook": "Notebook",
46
+ "training": "训练作业",
47
+ }
48
+
49
+ _CLOUD_BILL_NOTE_FALLBACK = "GPU、存储等云资源费用由您的云账户直接结算,请前往云厂商费用中心查看"
50
+
51
+
52
+ # ══════════════════ get 组(get jobs / get usage)══════════════════
53
+
54
+
55
+ @click.group()
56
+ @click.pass_context
57
+ def get(ctx: click.Context) -> None:
58
+ """查询资源(jobs / usage)"""
59
+ pass
60
+
61
+
62
+ @get.command("jobs")
63
+ @click.option("--status", default=None, help="按状态筛选(running/stopped/failed)")
64
+ @click.option("--kind", default=None, help="按类型筛选(Training/Inference/Notebook)")
65
+ @click.pass_context
66
+ def get_jobs(ctx: click.Context, status: str | None, kind: str | None) -> None:
67
+ """列出所有作业"""
68
+ json_mode: bool = ctx.obj["json_mode"]
69
+ store = ConfigStore()
70
+ if not store.token:
71
+ console.fatal_server("未登录", suggestion="请先执行 runw login")
72
+ return
73
+
74
+ client = ApiClient(store)
75
+ data = client.get_jobs(status=status, kind=kind)
76
+ job_list = data.get("data", []) if isinstance(data, dict) else data
77
+
78
+ if json_mode:
79
+ console.output(data if isinstance(data, dict) else {"data": job_list}, json_mode=True)
80
+ return
81
+
82
+ if not job_list:
83
+ console.info("暂无作业")
84
+ return
85
+
86
+ # 边界(2026-07-10,PRD §8):不展示云账单估算列——平台不替云厂商报钱
87
+ columns = ["名称", "类型", "状态", "GPU", "运行时长", "URL"]
88
+ rows = []
89
+ for j in job_list:
90
+ gpu_info = f"{j.get('gpu_count', 1)}×{j.get('gpu_type', '?')}"
91
+ elapsed = _format_duration(j.get("elapsed_seconds", 0))
92
+ rows.append([
93
+ j.get("name", j.get("gpuctl_job_id", "")),
94
+ j.get("job_type", j.get("kind", "")),
95
+ j.get("status", ""),
96
+ gpu_info,
97
+ elapsed,
98
+ j.get("url") or "-", # 推理/Notebook 访问地址;训练无
99
+ ])
100
+ console.table(columns, rows)
101
+
102
+
103
+ @get.command("usage")
104
+ @click.option("--from", "from_date", default=None, help="起始日期 YYYY-MM-DD")
105
+ @click.option("--to", "to_date", default=None, help="结束日期 YYYY-MM-DD")
106
+ @click.pass_context
107
+ def get_usage(ctx: click.Context, from_date: str | None, to_date: str | None) -> None:
108
+ """查看用量与平台账单(配额占用 + GPU 时长 + 订阅/超额)"""
109
+ json_mode: bool = ctx.obj["json_mode"]
110
+ store = ConfigStore()
111
+ if not store.token:
112
+ console.fatal_server("未登录", suggestion="请先执行 runw login")
113
+ return
114
+
115
+ client = ApiClient(store)
116
+ data = client.get_usage(from_date=from_date, to_date=to_date)
117
+
118
+ if json_mode:
119
+ console.output(data, json_mode=True)
120
+ return
121
+
122
+ # 边界(2026-07-10,PRD §8):只报事实(配额/时长/平台账单),
123
+ # 云侧金额不估算不展示——指路云厂商费用中心。
124
+ period = data.get("period", {})
125
+ summary = data.get("summary", {})
126
+
127
+ console.success(f"{period.get('from_date', '')} ~ {period.get('to_date', '')} 用量\n")
128
+
129
+ # 配额使用(并发数量制:在用 X / 上限 Y)
130
+ quota = data.get("quota", [])
131
+ if quota:
132
+ plan = data.get("plan", "free")
133
+ console.info(f"配额使用({plan} 套餐)")
134
+ for q in quota:
135
+ label = _QUOTA_LABELS.get(q.get("resource_type", ""), q.get("resource_type", ""))
136
+ current = q.get("current", 0)
137
+ limit = q.get("limit")
138
+ usage = f"{current}(不限)" if limit is None else f"{current} / {limit}"
139
+ console.info(f" {label}: {usage}")
140
+ console.info("")
141
+
142
+ # 资源用量(分型号 GPU 时长——超额计费的事实依据)
143
+ total_hours = summary.get("total_gpu_hours", 0)
144
+ if total_hours:
145
+ console.info(f"资源用量:GPU·小时 {_fnum(total_hours, 1)} hr")
146
+ for g in data.get("gpu_usage", []):
147
+ console.info(f" {g.get('gpu_type', '')}: {_fnum(g.get('gpu_hours', 0), 1)} h")
148
+ console.info("")
149
+ else:
150
+ console.info("本期暂无 GPU 用量\n")
151
+
152
+ # 平台账单(RunWhere 实际出账:订阅 + 超额)——本命令的核心
153
+ platform_bill = data.get("platform_bill", {})
154
+ if platform_bill:
155
+ console.info("平台账单(RunWhere 出账)")
156
+ console.info(f" 订阅已摊销: ¥{_fnum(platform_bill.get('subscription', 0), 2)}")
157
+ console.info(f" 超额预估: ¥{_fnum(platform_bill.get('overage', 0), 2)}")
158
+ console.info(f" 本期平台账单: ¥{_fnum(platform_bill.get('total', 0), 2)}\n")
159
+
160
+ console.info(data.get("cloud_bill_note") or _CLOUD_BILL_NOTE_FALLBACK)
161
+
162
+
163
+ # ══════════════════ describe 组(describe job)══════════════════
164
+
165
+
166
+ @click.group()
167
+ @click.pass_context
168
+ def describe(ctx: click.Context) -> None:
169
+ """查看资源详情(job)"""
170
+ pass
171
+
172
+
173
+ @describe.command("job")
174
+ @click.argument("job_name")
175
+ @click.pass_context
176
+ def describe_job(ctx: click.Context, job_name: str) -> None:
177
+ """查看作业详情"""
178
+ json_mode: bool = ctx.obj["json_mode"]
179
+ store = ConfigStore()
180
+ if not store.token:
181
+ console.fatal_server("未登录")
182
+ return
183
+
184
+ client = ApiClient(store)
185
+ data = client.describe_job(job_name)
186
+
187
+ if json_mode:
188
+ console.output(data, json_mode=True)
189
+ return
190
+
191
+ console.success(f"作业: {data.get('name', job_name)}")
192
+ console.info(f" ID: {data.get('job_id', data.get('gpuctl_job_id', ''))}")
193
+ console.info(f" 类型: {data.get('job_type', data.get('kind', ''))}")
194
+ console.info(f" 状态: {data.get('status', '')}")
195
+ if data.get("url"): # 推理/Notebook 访问地址
196
+ console.info(f" URL: {data['url']}")
197
+
198
+ metrics = data.get("gpu_metrics")
199
+ if metrics:
200
+ console.info("\n GPU 指标(运行期滚动统计):")
201
+ console.info(
202
+ f" 利用率: 平均 {metrics.get('gpu_util_avg', 0):.1f}% / 峰值 "
203
+ f"{metrics.get('gpu_util_max', 0):.1f}%"
204
+ )
205
+ console.info(
206
+ f" 显存峰值: {metrics.get('gpu_mem_used_max_gb', 0):.1f} GB"
207
+ )
208
+ console.info(f" 温度峰值: {metrics.get('gpu_temp_max', 0):.0f} °C")
209
+ console.info(f" 采样数: {metrics.get('samples_count', 0)}")
210
+
211
+ if data.get("platform_defaults"):
212
+ console.info("\n 平台自动行为:")
213
+ for key, val in data["platform_defaults"].items():
214
+ if isinstance(val, dict):
215
+ console.info(f" {key}: {val.get('value', '')}({val.get('reason', '')})")
216
+ else:
217
+ console.info(f" {key}: {val}")
218
+
219
+
220
+ # ══════════════════ stop 组(stop job)══════════════════
221
+
222
+
223
+ @click.group()
224
+ @click.pass_context
225
+ def stop(ctx: click.Context) -> None:
226
+ """停止资源(job)"""
227
+ pass
228
+
229
+
230
+ @stop.command("job")
231
+ @click.argument("job_name")
232
+ @click.pass_context
233
+ def stop_job(ctx: click.Context, job_name: str) -> None:
234
+ """停止运行中的作业(保留 checkpoint,释放 GPU)"""
235
+ json_mode: bool = ctx.obj["json_mode"]
236
+ store = ConfigStore()
237
+ if not store.token:
238
+ console.fatal_server("未登录")
239
+ return
240
+
241
+ client = ApiClient(store)
242
+ data = client.stop_job(job_name)
243
+
244
+ if json_mode:
245
+ console.output(data, json_mode=True)
246
+ else:
247
+ # 边界(2026-07-10,PRD §8):不展示云侧金额(final_cost 是内部审计口径)
248
+ console.success(f"作业 {job_name} 已停止,GPU 已释放,计费停止")
249
+
250
+
251
+ # ══════════════════ delete 组(delete job)══════════════════
252
+
253
+
254
+ @click.group()
255
+ @click.pass_context
256
+ def delete(ctx: click.Context) -> None:
257
+ """删除资源(job)"""
258
+ pass
259
+
260
+
261
+ @delete.command("job")
262
+ @click.argument("job_name")
263
+ @click.option("--force", is_flag=True, help="强制删除运行中的作业")
264
+ @click.pass_context
265
+ def delete_job(ctx: click.Context, job_name: str, force: bool) -> None:
266
+ """删除作业记录(不删 checkpoint / workspace 数据)"""
267
+ json_mode: bool = ctx.obj["json_mode"]
268
+ store = ConfigStore()
269
+ if not store.token:
270
+ console.fatal_server("未登录")
271
+ return
272
+
273
+ client = ApiClient(store)
274
+ data = client.delete_job(job_name, force=force)
275
+
276
+ if json_mode:
277
+ console.output(data, json_mode=True)
278
+ else:
279
+ console.success(f"作业 {job_name} 已删除")