fcloud-sdk 0.2.0__tar.gz → 0.2.1__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.
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/CHANGELOG.md +46 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/PKG-INFO +20 -9
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/README.md +19 -8
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/SKILL.md +7 -1
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/pyproject.toml +1 -1
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/SKILL.md +7 -1
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/__main__.py +1 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/_direct_bridge.py +5 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/help.py +4 -3
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/job.py +59 -4
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/processes.py +34 -1
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/sessions.py +124 -10
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/setup.py +33 -34
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/sweep_harvest.py +10 -1
- fcloud_sdk-0.2.1/src/fcloud/cli/usage.py +67 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/volume.py +5 -1
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/wait.py +7 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli_args.py +2 -1
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/client.py +24 -0
- fcloud_sdk-0.2.1/src/fcloud/login.py +236 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/setup_cmd.py +15 -3
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud_sdk.egg-info/PKG-INFO +20 -9
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud_sdk.egg-info/SOURCES.txt +6 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_direct_bridge.py +23 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_first_run_login.py +18 -15
- fcloud_sdk-0.2.1/tests/test_job_ls.py +99 -0
- fcloud_sdk-0.2.1/tests/test_login.py +202 -0
- fcloud_sdk-0.2.1/tests/test_logs_unknown_pid.py +84 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sessions_live_statuses.py +17 -0
- fcloud_sdk-0.2.1/tests/test_spend.py +203 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sweep_harvest.py +27 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_volume_cli_hardening.py +9 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_wait.py +26 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/CONTRIBUTING.md +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/LICENSE +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/MANIFEST.in +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/NOTICE +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/setup.cfg +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/__init__.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/_legacy_env.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/autoresearch/SETUP.md +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/autoresearch/program.template.md +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/__init__.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/attach.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/autoresearch.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/common.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/config_cmd.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/console.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/context.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/exec_cmd.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/files.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/hardware.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/interactive.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/main.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/migration.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/mount.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/output.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/registry.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/run.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/ssh.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/sweep.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/cli/sweep_watch.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/client_projects.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/client_sessions.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/client_volumes.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/config.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/direct.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/errors.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/fileset.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/image.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/job.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/providers/__init__.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/providers/requests_http.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/py.typed +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/session.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/shell.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/sweeps.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/tunnel.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/types.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/v2_connect.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/version.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/volume_wait.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud/volumes.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud_sdk.egg-info/dependency_links.txt +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud_sdk.egg-info/entry_points.txt +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud_sdk.egg-info/requires.txt +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/fcloud_sdk.egg-info/top_level.txt +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/src/foom/__init__.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/conftest.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_autoresearch.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_checkpoint_option.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_cli_args.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_cli_dispatch.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_cli_guards.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_cli_help.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_client.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_client_contracts_c.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_client_host_frames_contract.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_client_http_contract.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_client_telemetry.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_config.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_config_cmd.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_default_image.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_direct_fake_host.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_dotenv_precedence.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_download_volume_hint.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_emit_pid.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_exec_attach_retry.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_fileset.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_job.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_kill_records_first.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_legacy_shim.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_map_dag.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_map_default_image.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_map_window_flags.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_no_resume_gate.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_progress_narration.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_provider_env_default.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_queued_reason_render.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_rate_limit_ride.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_rebuild_respawn.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_resume_volume_validation.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_ride_cap.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_run_migration.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_run_volume_collision.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_run_wait_flags.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_secret_resolution.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_session_truth.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sessions_point_lookup.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_setup_cmd.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_shell_env.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_shell_interrupt.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_shell_volumes.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_skus.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_stop_refused_classification.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sweep_boost.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sweep_cost_optin.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sweep_lifecycle_ux.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sweep_retry_and_kill_truth.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sweep_status_view.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_sweeps_binding.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_tunnel.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_types.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_upload_s3_path.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_v2_connect.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_volume_download_delete.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_volume_preflight.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_volume_resolution.py +0 -0
- {fcloud_sdk-0.2.0 → fcloud_sdk-0.2.1}/tests/test_volume_wait.py +0 -0
|
@@ -4,6 +4,52 @@ All notable changes to the `fcloud` Python SDK and CLI are recorded here.
|
|
|
4
4
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
|
5
5
|
versions follow [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.2.1] - 2026-09-14
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `fcloud login`: sign in through the browser (device authorization). Shows a
|
|
13
|
+
short code, opens the approval page, and saves a key minted for this
|
|
14
|
+
machine (named `cli:<hostname>`); then installs the fcloud skill for the
|
|
15
|
+
coding agents present on the machine (`--agents auto`, the default; also
|
|
16
|
+
`all`, a list, or `none`). `--no-browser` prints the URL and code instead
|
|
17
|
+
of opening a browser. A fresh install's first command starts the same
|
|
18
|
+
login, and `fcloud setup` with no key delegates to it.
|
|
19
|
+
- Spend visibility: `fcloud sessions` shows `$/HR` (the rate billed on the
|
|
20
|
+
session's open usage interval while a host is held, 0 when cold), `SPENT`
|
|
21
|
+
(compute charged so far) and `AGE` (time on its current host) per row plus
|
|
22
|
+
a "Spending now" footer; `--json` rows carry `usd_per_hour`, `spent_usd`
|
|
23
|
+
and `host_age_seconds` (SDK: `Client.session_cost`). New `fcloud usage [--since DUR|TS] [--until TS]`
|
|
24
|
+
reports what the account was charged over a window (default 30 days) by
|
|
25
|
+
resource and session (SDK: `Client.billing_usage`).
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- `fcloud job ls` shows each job's COMMAND and EXIT code (from its process
|
|
29
|
+
index; `--json` rows carry `command` and `exit_code`), so rows are
|
|
30
|
+
tellable apart and failures visible without opening each job.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- `fcloud logs <sid> <pid>` with a pid the dispatcher has no record of now
|
|
34
|
+
exits 1 (`process_not_found`) like `wait` and `kill`, instead of printing
|
|
35
|
+
an empty stream with exit 0 (and `status: ""` in `--json`) that a poller
|
|
36
|
+
read as a finished, silent process. A just-spawned pid gets a few seconds
|
|
37
|
+
to reach the durable index before it is called unknown.
|
|
38
|
+
- `fcloud wait` with a `--timeout` shorter than the unknown-pid grace window
|
|
39
|
+
exits 1 for an unknown pid, not 75 (retry), so scripted callers stop
|
|
40
|
+
retrying a typo forever.
|
|
41
|
+
- Cold starts of `run`/`exec`/`job run` no longer dump every host status
|
|
42
|
+
frame (`⟵ session_status: building (phase_change) setup_timing ...`);
|
|
43
|
+
the fresh-allocation and reconnect paths now use the same quiet printer
|
|
44
|
+
as warm attaches.
|
|
45
|
+
- `fcloud sweep harvest` no longer pulls the session's virtual `_logs/`
|
|
46
|
+
directory (`_logs/processes.json`) for a results glob like `*.json`; name
|
|
47
|
+
`_logs/` in the glob to fetch logs.
|
|
48
|
+
- `fcloud sessions` sizes the HOST and SKU columns to the data (27-char AWS
|
|
49
|
+
host ids overflowed the fixed width on every row).
|
|
50
|
+
- `fcloud volume list` shows each volume's last update time.
|
|
51
|
+
- SKILL.md documents `fcloud sessions --limit N`.
|
|
52
|
+
|
|
7
53
|
## [0.2.0] - 2026-09-08
|
|
8
54
|
|
|
9
55
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fcloud-sdk
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Python SDK and CLI for the fcloud GPU compute platform
|
|
5
5
|
Author: fcloud
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -43,25 +43,35 @@ Requires Python 3.10+.
|
|
|
43
43
|
|
|
44
44
|
## Install
|
|
45
45
|
|
|
46
|
+
```bash
|
|
47
|
+
curl -fsSL https://fcloud-home.vercel.app/install.sh | sh
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
That installs the CLI from PyPI (via `uv` or `pipx`), signs you in through
|
|
51
|
+
the browser, and installs the fcloud skill for coding agents on your machine.
|
|
52
|
+
With your own Python:
|
|
53
|
+
|
|
46
54
|
```bash
|
|
47
55
|
pip install fcloud-sdk
|
|
56
|
+
fcloud login
|
|
48
57
|
```
|
|
49
58
|
|
|
50
59
|
## Setup
|
|
51
60
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
walks you through sign-up and saves the key you paste.
|
|
61
|
+
`fcloud login` opens https://fcloud-home.vercel.app to sign in (Google or
|
|
62
|
+
email) and add a card, then saves a key minted for this machine. Running any
|
|
63
|
+
`fcloud` command on a fresh install starts the same login.
|
|
56
64
|
|
|
57
65
|
```bash
|
|
58
|
-
fcloud
|
|
66
|
+
fcloud login [--no-browser] [--agents auto|all|cursor|claude|codex|none]
|
|
67
|
+
fcloud set_token <key> # already have a key (CI, agents, a second machine)
|
|
68
|
+
fcloud setup # save a key you already have; install agent skills
|
|
59
69
|
fcloud health # verify connectivity
|
|
60
70
|
```
|
|
61
71
|
|
|
62
|
-
`fcloud
|
|
63
|
-
|
|
64
|
-
|
|
72
|
+
`fcloud login` installs the fcloud skill file for the coding agents it finds
|
|
73
|
+
(Cursor, Claude Code, Codex — `--agents all` for every one) so an agent can
|
|
74
|
+
drive fcloud for you. `fcloud setup --agents none` skips that.
|
|
65
75
|
|
|
66
76
|
## Quick start
|
|
67
77
|
|
|
@@ -141,6 +151,7 @@ Batch
|
|
|
141
151
|
fcloud sweeps / fcloud sweep <status|logs|retry|cancel|wait> <name>
|
|
142
152
|
|
|
143
153
|
Setup
|
|
154
|
+
fcloud login [--no-browser] [--agents auto|all|cursor|claude|codex|none]
|
|
144
155
|
fcloud setup [--token KEY] [--agents all|cursor|claude|codex|none]
|
|
145
156
|
fcloud set_token <api-key>
|
|
146
157
|
fcloud --version
|
|
@@ -9,25 +9,35 @@ Requires Python 3.10+.
|
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
|
+
```bash
|
|
13
|
+
curl -fsSL https://fcloud-home.vercel.app/install.sh | sh
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
That installs the CLI from PyPI (via `uv` or `pipx`), signs you in through
|
|
17
|
+
the browser, and installs the fcloud skill for coding agents on your machine.
|
|
18
|
+
With your own Python:
|
|
19
|
+
|
|
12
20
|
```bash
|
|
13
21
|
pip install fcloud-sdk
|
|
22
|
+
fcloud login
|
|
14
23
|
```
|
|
15
24
|
|
|
16
25
|
## Setup
|
|
17
26
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
walks you through sign-up and saves the key you paste.
|
|
27
|
+
`fcloud login` opens https://fcloud-home.vercel.app to sign in (Google or
|
|
28
|
+
email) and add a card, then saves a key minted for this machine. Running any
|
|
29
|
+
`fcloud` command on a fresh install starts the same login.
|
|
22
30
|
|
|
23
31
|
```bash
|
|
24
|
-
fcloud
|
|
32
|
+
fcloud login [--no-browser] [--agents auto|all|cursor|claude|codex|none]
|
|
33
|
+
fcloud set_token <key> # already have a key (CI, agents, a second machine)
|
|
34
|
+
fcloud setup # save a key you already have; install agent skills
|
|
25
35
|
fcloud health # verify connectivity
|
|
26
36
|
```
|
|
27
37
|
|
|
28
|
-
`fcloud
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
`fcloud login` installs the fcloud skill file for the coding agents it finds
|
|
39
|
+
(Cursor, Claude Code, Codex — `--agents all` for every one) so an agent can
|
|
40
|
+
drive fcloud for you. `fcloud setup --agents none` skips that.
|
|
31
41
|
|
|
32
42
|
## Quick start
|
|
33
43
|
|
|
@@ -107,6 +117,7 @@ Batch
|
|
|
107
117
|
fcloud sweeps / fcloud sweep <status|logs|retry|cancel|wait> <name>
|
|
108
118
|
|
|
109
119
|
Setup
|
|
120
|
+
fcloud login [--no-browser] [--agents auto|all|cursor|claude|codex|none]
|
|
110
121
|
fcloud setup [--token KEY] [--agents all|cursor|claude|codex|none]
|
|
111
122
|
fcloud set_token <api-key>
|
|
112
123
|
fcloud --version
|
|
@@ -25,6 +25,9 @@ fcloud set_token <api_key> # one-time; saves to ~/.fcloud/token
|
|
|
25
25
|
fcloud health # verify connectivity
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
(A human sets up with `fcloud login`, which signs in through the browser and
|
|
29
|
+
mints a key for the machine; agents use `set_token` with a key they were given.)
|
|
30
|
+
|
|
28
31
|
Token resolution order:
|
|
29
32
|
|
|
30
33
|
1. `api_key=` parameter to `Client()`
|
|
@@ -303,7 +306,8 @@ filesystem later; a **job** for batch/CI-style runs with declared outputs.
|
|
|
303
306
|
| Command | Description |
|
|
304
307
|
| ----------------------------- | ---------------------------------------------------- |
|
|
305
308
|
| `fcloud create [--sku SKU]` | Create a session (cold — $0, no host, until first used) |
|
|
306
|
-
| `fcloud sessions [--all]`
|
|
309
|
+
| `fcloud sessions [--all] [--limit N]` | List sessions with `$/HR` (billed rate), `SPENT` (compute so far) and `AGE` (time on its host) per row and a spending-now total (`--all --json` can be large; bound it with `--limit`) |
|
|
310
|
+
| `fcloud usage [--since 24h]` | What the account has been charged over a window (default 30 days), by resource and session |
|
|
307
311
|
| `fcloud stop <session-id> [--wait]` | Stop a session now: halt GPU spend (files kept). `--wait` blocks until the workspace manifest is durable |
|
|
308
312
|
|
|
309
313
|
|
|
@@ -1215,6 +1219,8 @@ host (files restored from cloud storage) the next time you use it. You never run
|
|
|
1215
1219
|
**Warmth**: `fcloud sessions` reports a session's state — `hot` (running, spending),
|
|
1216
1220
|
`warm` (idle host held, spending), `stopping` (finishing teardown), `cold`
|
|
1217
1221
|
(stopped, $0, resumes on use), or `preparing` (coming online).
|
|
1222
|
+
The `$/HR` column is the rate billed on the session's open usage interval while a
|
|
1223
|
+
host is held (0 when cold); `SPENT` is compute charged so far; the footer sums `$/HR`. `fcloud usage` reports what was actually charged.
|
|
1218
1224
|
|
|
1219
1225
|
**Image**: Declarative container spec. Layers are cached by content hash. First run
|
|
1220
1226
|
on a host triggers a build (30–120s); subsequent runs with the same image are instant.
|
|
@@ -25,6 +25,9 @@ fcloud set_token <api_key> # one-time; saves to ~/.fcloud/token
|
|
|
25
25
|
fcloud health # verify connectivity
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
(A human sets up with `fcloud login`, which signs in through the browser and
|
|
29
|
+
mints a key for the machine; agents use `set_token` with a key they were given.)
|
|
30
|
+
|
|
28
31
|
Token resolution order:
|
|
29
32
|
|
|
30
33
|
1. `api_key=` parameter to `Client()`
|
|
@@ -303,7 +306,8 @@ filesystem later; a **job** for batch/CI-style runs with declared outputs.
|
|
|
303
306
|
| Command | Description |
|
|
304
307
|
| ----------------------------- | ---------------------------------------------------- |
|
|
305
308
|
| `fcloud create [--sku SKU]` | Create a session (cold — $0, no host, until first used) |
|
|
306
|
-
| `fcloud sessions [--all]`
|
|
309
|
+
| `fcloud sessions [--all] [--limit N]` | List sessions with `$/HR` (billed rate), `SPENT` (compute so far) and `AGE` (time on its host) per row and a spending-now total (`--all --json` can be large; bound it with `--limit`) |
|
|
310
|
+
| `fcloud usage [--since 24h]` | What the account has been charged over a window (default 30 days), by resource and session |
|
|
307
311
|
| `fcloud stop <session-id> [--wait]` | Stop a session now: halt GPU spend (files kept). `--wait` blocks until the workspace manifest is durable |
|
|
308
312
|
|
|
309
313
|
|
|
@@ -1215,6 +1219,8 @@ host (files restored from cloud storage) the next time you use it. You never run
|
|
|
1215
1219
|
**Warmth**: `fcloud sessions` reports a session's state — `hot` (running, spending),
|
|
1216
1220
|
`warm` (idle host held, spending), `stopping` (finishing teardown), `cold`
|
|
1217
1221
|
(stopped, $0, resumes on use), or `preparing` (coming online).
|
|
1222
|
+
The `$/HR` column is the rate billed on the session's open usage interval while a
|
|
1223
|
+
host is held (0 when cold); `SPENT` is compute charged so far; the footer sums `$/HR`. `fcloud usage` reports what was actually charged.
|
|
1218
1224
|
|
|
1219
1225
|
**Image**: Declarative container spec. Layers are cached by content hash. First run
|
|
1220
1226
|
on a host triggers a build (30–120s); subsequent runs with the same image are instant.
|
|
@@ -593,6 +593,10 @@ class _DirectBridge:
|
|
|
593
593
|
api_key=self._cfg.api_key,
|
|
594
594
|
sku=sku,
|
|
595
595
|
image_spec=spec_json,
|
|
596
|
+
# The same printer the attach path uses: without it the
|
|
597
|
+
# DirectClient default echoes every host frame, and a cold
|
|
598
|
+
# start dumps a dozen setup_timing lines on the user.
|
|
599
|
+
on_status=_persistent_session_status_printer,
|
|
596
600
|
)
|
|
597
601
|
)
|
|
598
602
|
try:
|
|
@@ -1519,6 +1523,7 @@ class _DirectBridge:
|
|
|
1519
1523
|
allocation,
|
|
1520
1524
|
dispatcher_url=self._cfg.dispatcher_url,
|
|
1521
1525
|
api_key=self._cfg.api_key,
|
|
1526
|
+
on_status=_persistent_session_status_printer,
|
|
1522
1527
|
)
|
|
1523
1528
|
)
|
|
1524
1529
|
try:
|
|
@@ -30,6 +30,7 @@ _HELP_GROUPS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
|
|
|
30
30
|
("sessions", "fcloud sessions [SID] [--all] [--limit N]"),
|
|
31
31
|
("history", "fcloud history <SID> [--limit N]"),
|
|
32
32
|
("stop", "fcloud stop <SID>"),
|
|
33
|
+
("usage", "fcloud usage [--since DUR]"),
|
|
33
34
|
)),
|
|
34
35
|
("Files", (
|
|
35
36
|
("upload", "fcloud upload [--on SID] <local> [remote]"),
|
|
@@ -50,7 +51,7 @@ _HELP_GROUPS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
|
|
|
50
51
|
("autoresearch", "fcloud autoresearch init [dir]"),
|
|
51
52
|
)),
|
|
52
53
|
("Setup", (
|
|
53
|
-
("login", "fcloud login"),
|
|
54
|
+
("login", "fcloud login [--no-browser] [--agents TOOL]"),
|
|
54
55
|
("setup", "fcloud setup [--token KEY] [--agents TOOL]"),
|
|
55
56
|
("set_token", "fcloud set_token <api-key>"),
|
|
56
57
|
("config", "fcloud config <show|get|set|unset> [KEY] [VALUE]"),
|
|
@@ -129,8 +130,8 @@ def _print_command_groups() -> None:
|
|
|
129
130
|
def print_help():
|
|
130
131
|
print("fcloud — GPU compute from the command line\n")
|
|
131
132
|
print("Setup:")
|
|
132
|
-
print(" fcloud login
|
|
133
|
-
print(" fcloud setup Save
|
|
133
|
+
print(" fcloud login Sign in with your browser + install agent skills")
|
|
134
|
+
print(" fcloud setup Save an API key you already have")
|
|
134
135
|
print(" fcloud health Check connectivity\n")
|
|
135
136
|
print(_HELP_CONCEPTS + "\n")
|
|
136
137
|
_print_command_groups()
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
4
|
import time
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
6
|
|
|
6
7
|
from fcloud.cli import output, sessions
|
|
7
8
|
from fcloud.cli.common import (
|
|
@@ -223,22 +224,76 @@ def _job_ls(args: list[str]) -> None:
|
|
|
223
224
|
reverse=True)
|
|
224
225
|
if limit_n > 0:
|
|
225
226
|
jobs = jobs[:limit_n]
|
|
227
|
+
runs = _job_runs(client, [s.session_id for s in jobs])
|
|
226
228
|
if output._JSON_OUTPUT:
|
|
227
229
|
output._out({"jobs": [
|
|
228
230
|
{"session_id": s.session_id, "status": s.status,
|
|
229
231
|
"state": _session_state(s), "lifecycle": s.lifecycle,
|
|
230
|
-
"created_at": s.created_at
|
|
232
|
+
"created_at": s.created_at,
|
|
233
|
+
"command": runs[s.session_id]["command"],
|
|
234
|
+
"exit_code": runs[s.session_id]["exit_code"]}
|
|
231
235
|
for s in jobs]})
|
|
232
236
|
return
|
|
233
237
|
if not jobs:
|
|
234
238
|
print("No jobs. Run one with: fcloud job run -- <cmd>"
|
|
235
239
|
+ ("" if show_all else " (--all includes finished jobs)"))
|
|
236
240
|
return
|
|
237
|
-
print(f"{'JOB':<22} {'STATE':<12} {'CREATED'}")
|
|
238
|
-
print("-" *
|
|
241
|
+
print(f"{'JOB':<22} {'STATE':<12} {'EXIT':<5} {'CREATED':<21} COMMAND")
|
|
242
|
+
print("-" * 100)
|
|
239
243
|
for s in jobs:
|
|
244
|
+
run = runs[s.session_id]
|
|
245
|
+
code = run["exit_code"]
|
|
240
246
|
print(f"{s.session_id:<22} {_session_state(s):<12} "
|
|
241
|
-
f"{s.created_at or ''}"
|
|
247
|
+
f"{'' if code is None else code:<5} {s.created_at or '':<21} "
|
|
248
|
+
f"{_command_cell(run['command'])}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# One index read per listed job — bounded by the rows shown, and fanned out
|
|
252
|
+
# so a 50-row listing costs one round-trip's latency, not fifty.
|
|
253
|
+
_JOB_LS_INDEX_WORKERS = 8
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _job_runs(client, sids: list[str]) -> dict[str, dict]:
|
|
257
|
+
"""The command and exit code of each job's run, from its process index.
|
|
258
|
+
|
|
259
|
+
A job is one spawned command, so the index's first record IS the job;
|
|
260
|
+
without it `job ls` is a list of opaque session ids that cannot say
|
|
261
|
+
which row failed or which was which. Missing or unreadable index →
|
|
262
|
+
blanks: a listing must never fail because one job's log is unreadable.
|
|
263
|
+
"""
|
|
264
|
+
if not sids:
|
|
265
|
+
return {}
|
|
266
|
+
with ThreadPoolExecutor(max_workers=_JOB_LS_INDEX_WORKERS) as pool:
|
|
267
|
+
return dict(zip(sids, pool.map(
|
|
268
|
+
lambda sid: _job_run_from_index(client, sid), sids), strict=True))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _job_run_from_index(client, sid: str) -> dict:
|
|
272
|
+
try:
|
|
273
|
+
procs = client.list_session_processes(sid)
|
|
274
|
+
except Exception: # noqa: BLE001 — best-effort enrichment
|
|
275
|
+
procs = []
|
|
276
|
+
first = min(procs, key=lambda p: p.get("started_at_ms") or 0,
|
|
277
|
+
default=None)
|
|
278
|
+
if not first:
|
|
279
|
+
return {"command": None, "exit_code": None}
|
|
280
|
+
command = first.get("command")
|
|
281
|
+
if isinstance(command, list):
|
|
282
|
+
# `job run` wraps a string command in a login shell (see
|
|
283
|
+
# fcloud.job._shape_command); show what the user typed, not the
|
|
284
|
+
# wrapper.
|
|
285
|
+
if len(command) == 3 and command[1] == "-lc" \
|
|
286
|
+
and command[0] in ("/bin/sh", "bash", "sh"):
|
|
287
|
+
command = [command[2]]
|
|
288
|
+
command = " ".join(str(c) for c in command)
|
|
289
|
+
return {"command": command or None, "exit_code": first.get("exit_code")}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _command_cell(command: str | None, width: int = 40) -> str:
|
|
293
|
+
if not command:
|
|
294
|
+
return ""
|
|
295
|
+
one_line = " ".join(command.split())
|
|
296
|
+
return one_line if len(one_line) <= width else one_line[:width - 1] + "…"
|
|
242
297
|
|
|
243
298
|
|
|
244
299
|
def _job_logs(args: list[str]) -> None:
|
|
@@ -18,7 +18,12 @@ from fcloud.cli.console import _pid_emitter
|
|
|
18
18
|
from fcloud.cli.context import _require_volumes_exist, _resolve_volume_mounts
|
|
19
19
|
from fcloud.cli.migration import _follow_reconnect, _tail_follow_delta
|
|
20
20
|
from fcloud.cli.registry import cmd
|
|
21
|
-
from fcloud.cli.wait import
|
|
21
|
+
from fcloud.cli.wait import (
|
|
22
|
+
_confirm_unknown_process,
|
|
23
|
+
_print_lost_process_notice,
|
|
24
|
+
_process_lost,
|
|
25
|
+
_process_unregistered,
|
|
26
|
+
)
|
|
22
27
|
from fcloud.cli_args import SPECS, split
|
|
23
28
|
from fcloud.client import Client, SessionNotFoundError
|
|
24
29
|
from fcloud.types import ProcessStatus
|
|
@@ -150,10 +155,38 @@ def _logs_dispatch(client: Client, sid: str, pid: str, opts: dict) -> None:
|
|
|
150
155
|
session_status = log_result.session_status
|
|
151
156
|
if not log_result.is_terminal and not session_status:
|
|
152
157
|
session_status = _authoritative_session_status(client, sid)
|
|
158
|
+
if _process_unregistered(log_result):
|
|
159
|
+
_refuse_unknown_process(client, sid, pid)
|
|
153
160
|
_print_log_result(log_result, sid=sid, pid=pid,
|
|
154
161
|
session_status=session_status)
|
|
155
162
|
|
|
156
163
|
|
|
164
|
+
# A just-spawned process reaches the durable index a second or two after
|
|
165
|
+
# `spawn` returns; a one-shot read inside that window must not call the
|
|
166
|
+
# pid unknown. Short — `logs` is interactive — and zeroed by tests.
|
|
167
|
+
_LOGS_UNKNOWN_PID_GRACE_S = 4.0
|
|
168
|
+
_LOGS_UNKNOWN_PID_POLL_S = 1.0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _refuse_unknown_process(client: Client, sid: str, pid: str) -> None:
|
|
172
|
+
"""Exit 1 (process_not_found) for a pid the index has no record of.
|
|
173
|
+
|
|
174
|
+
The durable read answers 200 with an empty stream and no status for
|
|
175
|
+
any pid at all, so without this a typo'd id printed as a finished,
|
|
176
|
+
silent process: status "" is not "running", and a poller following
|
|
177
|
+
the docs read it as success. The index is the authority, re-checked
|
|
178
|
+
briefly so a fresh spawn's flush lag isn't mistaken for a bad id."""
|
|
179
|
+
deadline = time.monotonic() + _LOGS_UNKNOWN_PID_GRACE_S
|
|
180
|
+
while True:
|
|
181
|
+
if not _confirm_unknown_process(client, sid, pid):
|
|
182
|
+
# Recorded (or the index read failed): print what we have
|
|
183
|
+
# rather than blame the caller on a transient.
|
|
184
|
+
return
|
|
185
|
+
if time.monotonic() >= deadline:
|
|
186
|
+
_fail_process_not_found(sid, pid)
|
|
187
|
+
time.sleep(_LOGS_UNKNOWN_PID_POLL_S)
|
|
188
|
+
|
|
189
|
+
|
|
157
190
|
def _parse_logs_args(args: list[str]) -> tuple[str, str, dict]:
|
|
158
191
|
sid = ""
|
|
159
192
|
pid = ""
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
import sys
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
6
|
from datetime import datetime, timezone
|
|
6
7
|
|
|
7
8
|
from fcloud.cli import output
|
|
@@ -214,6 +215,7 @@ def cmd_sessions(args: list[str]) -> None:
|
|
|
214
215
|
truncated = limit_n > 0 and total > limit_n
|
|
215
216
|
if limit_n > 0:
|
|
216
217
|
sessions = sessions[:limit_n]
|
|
218
|
+
costs = _session_costs(client, sessions)
|
|
217
219
|
if output._JSON_OUTPUT:
|
|
218
220
|
output._out({"sessions": [
|
|
219
221
|
{"session_id": s.session_id, "project_id": s.project_id,
|
|
@@ -232,6 +234,11 @@ def cmd_sessions(args: list[str]) -> None:
|
|
|
232
234
|
# False = owner opted out of checkpoint/restore (preemption
|
|
233
235
|
# rebuilds cold, no region pin).
|
|
234
236
|
"checkpoint": s.checkpoint,
|
|
237
|
+
# What this row costs right now and for how long it has: the
|
|
238
|
+
# first question in a beta ("what am I paying for?").
|
|
239
|
+
"usd_per_hour": _session_rate(s, costs),
|
|
240
|
+
"spent_usd": _session_spent(s, costs),
|
|
241
|
+
"host_age_seconds": _session_host_age_s(s),
|
|
235
242
|
"provisioning": s.provisioning}
|
|
236
243
|
for s in sessions],
|
|
237
244
|
"total": total, "returned": len(sessions),
|
|
@@ -241,16 +248,7 @@ def cmd_sessions(args: list[str]) -> None:
|
|
|
241
248
|
print("No active sessions." + ("" if show_all
|
|
242
249
|
else " Try --all to include cold/failed/dead."))
|
|
243
250
|
return
|
|
244
|
-
|
|
245
|
-
print("-" * 85)
|
|
246
|
-
for s in sessions:
|
|
247
|
-
state = _session_state(s)
|
|
248
|
-
detail = _provisioning_label(s)
|
|
249
|
-
if detail:
|
|
250
|
-
state = f"{state}: {detail}"
|
|
251
|
-
if not s.checkpoint:
|
|
252
|
-
state += " [no-checkpoint]"
|
|
253
|
-
print(f"{s.session_id:<30} {s.host_id:<25} {s.sku:<18} {state}")
|
|
251
|
+
_print_sessions_table(sessions, costs)
|
|
254
252
|
if truncated:
|
|
255
253
|
print(f"\nShowing {len(sessions)} of {total} sessions. "
|
|
256
254
|
f"Use --limit N (or --limit 0 for all).")
|
|
@@ -260,6 +258,122 @@ def cmd_sessions(args: list[str]) -> None:
|
|
|
260
258
|
"ended = finished job (never resumes)")
|
|
261
259
|
|
|
262
260
|
|
|
261
|
+
def _print_sessions_table(sessions: list, costs: dict[str, dict]) -> None:
|
|
262
|
+
# Sized to the data: a fixed HOST width overflowed into SKU on every
|
|
263
|
+
# row for 27-char AWS host ids.
|
|
264
|
+
host_w = max(25, *(len(s.host_id) for s in sessions))
|
|
265
|
+
sku_w = max(18, *(len(s.sku) for s in sessions))
|
|
266
|
+
print(f"{'SESSION ID':<30} {'HOST':<{host_w}} {'SKU':<{sku_w}} "
|
|
267
|
+
f"{'$/HR':>7} {'SPENT':>7} {'AGE':>7} STATE")
|
|
268
|
+
print("-" * (30 + host_w + sku_w + 36))
|
|
269
|
+
for s in sessions:
|
|
270
|
+
state = _session_state(s)
|
|
271
|
+
detail = _provisioning_label(s)
|
|
272
|
+
if detail:
|
|
273
|
+
state = f"{state}: {detail}"
|
|
274
|
+
if not s.checkpoint:
|
|
275
|
+
state += " [no-checkpoint]"
|
|
276
|
+
rate, spent = _session_rate(s, costs), _session_spent(s, costs)
|
|
277
|
+
age = _session_host_age_s(s)
|
|
278
|
+
print(f"{s.session_id:<30} {s.host_id:<{host_w}} {s.sku:<{sku_w}} "
|
|
279
|
+
f"{_fmt_usd(rate):>7} {_fmt_usd(spent):>7} "
|
|
280
|
+
f"{_fmt_age(age) if age is not None else '-':>7} {state}")
|
|
281
|
+
print(_spend_footer(sessions, costs))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# States in which a host is held for the session and the meter runs.
|
|
285
|
+
_SPENDING_STATES = frozenset({"hot", "warm", "stopping"})
|
|
286
|
+
_COST_READ_WORKERS = 8
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _session_costs(client, sessions: list) -> dict[str, dict]:
|
|
290
|
+
"""Session id -> its billing record, for the sessions holding a host.
|
|
291
|
+
|
|
292
|
+
The rate a session accrues at is the price snapshotted on its open
|
|
293
|
+
usage interval — the admin price book keyed by SKU, falling back to
|
|
294
|
+
the host's internal cost — so it is read from the session's own cost
|
|
295
|
+
record, never re-derived. One read per spending row (cold rows cost
|
|
296
|
+
$0 and are skipped), fanned out. Best-effort: a billing-less
|
|
297
|
+
environment (501) or a failed read leaves the columns blank; the
|
|
298
|
+
listing itself never fails over pricing."""
|
|
299
|
+
spending = [s for s in sessions if _session_state(s) in _SPENDING_STATES]
|
|
300
|
+
if not spending:
|
|
301
|
+
return {}
|
|
302
|
+
|
|
303
|
+
def _read(sid: str) -> dict | None:
|
|
304
|
+
try:
|
|
305
|
+
return client.session_cost(sid)
|
|
306
|
+
except Exception: # noqa: BLE001 — pricing is decoration on a listing
|
|
307
|
+
return None
|
|
308
|
+
with ThreadPoolExecutor(max_workers=_COST_READ_WORKERS) as pool:
|
|
309
|
+
got = list(pool.map(_read, [s.session_id for s in spending]))
|
|
310
|
+
return {s.session_id: rec for s, rec in zip(spending, got, strict=True)
|
|
311
|
+
if rec is not None}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _session_rate(s, costs: dict[str, dict]) -> float | None:
|
|
315
|
+
"""$/hr the session is accruing now: the open interval's snapshotted
|
|
316
|
+
price while a host is held, 0 for a cold/ended session, None when
|
|
317
|
+
the cost record could not be read."""
|
|
318
|
+
if _session_state(s) not in _SPENDING_STATES:
|
|
319
|
+
return 0.0
|
|
320
|
+
rec = costs.get(s.session_id)
|
|
321
|
+
if rec is None:
|
|
322
|
+
return None
|
|
323
|
+
intervals = rec.get("intervals") or []
|
|
324
|
+
open_ivs = [iv for iv in intervals if iv.get("open")]
|
|
325
|
+
current = (open_ivs or intervals)[-1:] or [{}]
|
|
326
|
+
return (current[0].get("price_usd_micros_per_hour") or 0) / 1_000_000
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _session_spent(s, costs: dict[str, dict]) -> float | None:
|
|
330
|
+
"""Compute charged so far on this session, when its record was read."""
|
|
331
|
+
rec = costs.get(s.session_id)
|
|
332
|
+
if rec is None:
|
|
333
|
+
return None
|
|
334
|
+
return (rec.get("compute_usd_micros") or 0) / 1_000_000
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _fmt_usd(amount: float | None) -> str:
|
|
338
|
+
if amount is None:
|
|
339
|
+
return "-"
|
|
340
|
+
# Cents-scale rates (a $0.015/hr CPU box) must not round to 0.00/0.02.
|
|
341
|
+
return f"{amount:.2f}" if amount == 0 or amount >= 0.1 else f"{amount:.4f}"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _session_host_age_s(s) -> int | None:
|
|
345
|
+
"""Seconds the session has held its current host, or None off-host."""
|
|
346
|
+
if _session_state(s) not in _SPENDING_STATES:
|
|
347
|
+
return None
|
|
348
|
+
since = _parse_api_timestamp(s.allocated_at or s.created_at)
|
|
349
|
+
if since is None:
|
|
350
|
+
return None
|
|
351
|
+
return max(0, int((datetime.now(timezone.utc) - since).total_seconds()))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _fmt_age(seconds: int) -> str:
|
|
355
|
+
if seconds < 60:
|
|
356
|
+
return f"{seconds}s"
|
|
357
|
+
if seconds < 3600:
|
|
358
|
+
return f"{seconds // 60}m"
|
|
359
|
+
if seconds < 86400:
|
|
360
|
+
return f"{seconds // 3600}h{(seconds % 3600) // 60:02d}m"
|
|
361
|
+
return f"{seconds // 86400}d{(seconds % 86400) // 3600}h"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _spend_footer(sessions: list, costs: dict[str, dict]) -> str:
|
|
365
|
+
spending = [s for s in sessions if _session_state(s) in _SPENDING_STATES]
|
|
366
|
+
if not spending:
|
|
367
|
+
return "\nSpending now: $0/hr (no session holds a host)"
|
|
368
|
+
rates = [_session_rate(s, costs) for s in spending]
|
|
369
|
+
known = [r for r in rates if r is not None]
|
|
370
|
+
line = (f"\nSpending now: ${_fmt_usd(sum(known))}/hr across "
|
|
371
|
+
f"{len(spending)} session(s) holding a host")
|
|
372
|
+
if len(known) < len(spending):
|
|
373
|
+
line += f" ({len(spending) - len(known)} with no billing record)"
|
|
374
|
+
return line + " — stop one with: fcloud stop <session-id>"
|
|
375
|
+
|
|
376
|
+
|
|
263
377
|
@cmd("history", "fcloud history <session-id> [--limit N]",
|
|
264
378
|
"Show a session's lifecycle timeline")
|
|
265
379
|
def cmd_history(args: list[str]) -> None:
|