manifold-cli 0.0.39__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 (102) hide show
  1. manifold_cli/__init__.py +3 -0
  2. manifold_cli/__main__.py +4 -0
  3. manifold_cli/bridge.py +70 -0
  4. manifold_cli/cli.py +54 -0
  5. manifold_cli/clients/__init__.py +0 -0
  6. manifold_cli/clients/auth_flow.py +169 -0
  7. manifold_cli/clients/credentials.py +53 -0
  8. manifold_cli/clients/docker_engine.py +43 -0
  9. manifold_cli/clients/image.py +130 -0
  10. manifold_cli/clients/manifold.py +1288 -0
  11. manifold_cli/clients/runner_home.py +165 -0
  12. manifold_cli/clients/workos.py +99 -0
  13. manifold_cli/commands/__init__.py +0 -0
  14. manifold_cli/commands/auth.py +101 -0
  15. manifold_cli/commands/benchmark.py +539 -0
  16. manifold_cli/commands/organization.py +154 -0
  17. manifold_cli/commands/policy.py +749 -0
  18. manifold_cli/commands/run.py +337 -0
  19. manifold_cli/commands/run_log.py +291 -0
  20. manifold_cli/commands/runner.py +852 -0
  21. manifold_cli/commands/table.py +101 -0
  22. manifold_cli/commands/token.py +144 -0
  23. manifold_cli/dependencies.py +116 -0
  24. manifold_cli/models/__init__.py +0 -0
  25. manifold_cli/models/auth.py +49 -0
  26. manifold_cli/models/benchmark.py +87 -0
  27. manifold_cli/models/catalog.py +189 -0
  28. manifold_cli/models/credentials.py +19 -0
  29. manifold_cli/models/errors.py +121 -0
  30. manifold_cli/models/log.py +114 -0
  31. manifold_cli/models/observability.py +121 -0
  32. manifold_cli/models/organization.py +19 -0
  33. manifold_cli/models/pagination.py +59 -0
  34. manifold_cli/models/policy.py +49 -0
  35. manifold_cli/models/run.py +381 -0
  36. manifold_cli/models/runner.py +186 -0
  37. manifold_cli/models/runner_home.py +69 -0
  38. manifold_cli/models/task_queue.py +40 -0
  39. manifold_cli/models/token.py +39 -0
  40. manifold_cli/replay/__init__.py +68 -0
  41. manifold_cli/replay/log.py +814 -0
  42. manifold_cli/runner/__init__.py +72 -0
  43. manifold_cli/runner/api_retry.py +36 -0
  44. manifold_cli/runner/benchmark/__init__.py +800 -0
  45. manifold_cli/runner/benchmark/live_view.py +248 -0
  46. manifold_cli/runner/benchmark/preassigned_scheduling.py +325 -0
  47. manifold_cli/runner/benchmark/recording.py +1208 -0
  48. manifold_cli/runner/benchmark/scheduling.py +256 -0
  49. manifold_cli/runner/benchmark/task_queue_scheduling.py +717 -0
  50. manifold_cli/runner/benchmark/task_server.py +123 -0
  51. manifold_cli/runner/benchmark/video.py +124 -0
  52. manifold_cli/runner/benchmark_steps.py +178 -0
  53. manifold_cli/runner/cancellation.py +22 -0
  54. manifold_cli/runner/capture.py +282 -0
  55. manifold_cli/runner/container.py +370 -0
  56. manifold_cli/runner/current_dispatch.py +25 -0
  57. manifold_cli/runner/daemon.py +520 -0
  58. manifold_cli/runner/dispatch.py +38 -0
  59. manifold_cli/runner/gpu.py +182 -0
  60. manifold_cli/runner/heartbeat.py +202 -0
  61. manifold_cli/runner/loopback.py +102 -0
  62. manifold_cli/runner/loopback_bench.py +183 -0
  63. manifold_cli/runner/loopback_policy.py +39 -0
  64. manifold_cli/runner/output.py +218 -0
  65. manifold_cli/runner/pairing_acceptance.py +57 -0
  66. manifold_cli/runner/plan.py +369 -0
  67. manifold_cli/runner/policy.py +350 -0
  68. manifold_cli/runner/policy_steps.py +164 -0
  69. manifold_cli/runner/preparation.py +47 -0
  70. manifold_cli/runner/process.py +219 -0
  71. manifold_cli/runner/ram.py +17 -0
  72. manifold_cli/runner/resource_usage.py +265 -0
  73. manifold_cli/runner/served.py +218 -0
  74. manifold_cli/runner/service.py +120 -0
  75. manifold_cli/runner/side_failure.py +68 -0
  76. manifold_cli/runner/signature.py +98 -0
  77. manifold_cli/runner/source.py +66 -0
  78. manifold_cli/runner/stages.py +241 -0
  79. manifold_cli/runner/step_timing.py +229 -0
  80. manifold_cli/runner/worker.py +534 -0
  81. manifold_cli/runner/workload.py +401 -0
  82. manifold_cli/services/__init__.py +0 -0
  83. manifold_cli/services/auth.py +89 -0
  84. manifold_cli/services/benchmark.py +357 -0
  85. manifold_cli/services/organization.py +22 -0
  86. manifold_cli/services/policy.py +234 -0
  87. manifold_cli/services/run.py +304 -0
  88. manifold_cli/services/runner.py +207 -0
  89. manifold_cli/services/runner_daemon.py +146 -0
  90. manifold_cli/services/token.py +52 -0
  91. manifold_cli/settings.py +30 -0
  92. manifold_cli/transport/__init__.py +31 -0
  93. manifold_cli/transport/diagnostics.py +72 -0
  94. manifold_cli/transport/interfaces.py +69 -0
  95. manifold_cli/transport/iroh.py +317 -0
  96. manifold_cli/transport/registry.py +34 -0
  97. manifold_cli/transport/tcp.py +146 -0
  98. manifold_cli-0.0.39.dist-info/METADATA +406 -0
  99. manifold_cli-0.0.39.dist-info/RECORD +102 -0
  100. manifold_cli-0.0.39.dist-info/WHEEL +4 -0
  101. manifold_cli-0.0.39.dist-info/entry_points.txt +2 -0
  102. manifold_cli-0.0.39.dist-info/licenses/LICENSE +373 -0
@@ -0,0 +1,165 @@
1
+ """Reader and writer for the one file a runner home holds.
2
+
3
+ The homes live under one root, `<config dir>/runners` unless
4
+ `MANIFOLD_RUNNERS_DIR` moves them, one directory per runner id. A box with one
5
+ runner has one home and needs no `--runner`; a box with several is asked which.
6
+ """
7
+
8
+ import logging
9
+ from pathlib import Path
10
+ from typing import NamedTuple
11
+ from uuid import UUID
12
+
13
+ from pydantic import ValidationError
14
+
15
+ from manifold_cli.clients.credentials import write_private_file
16
+ from manifold_cli.models.errors import AmbiguousRunnerHomeError, NoRunnerHomeError
17
+ from manifold_cli.models.runner_home import HOME_FILE_NAME, RunnerHome
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class RunnerHomes:
23
+ """The root the runner homes sit under, one directory per runner id."""
24
+
25
+ def __init__(self, root: Path):
26
+ self._root = root
27
+
28
+ def resolve(self, handle: str | None) -> "RunnerHomeClient":
29
+ """The home a command acts on, which has to exist.
30
+
31
+ Falls back to the only home when the caller passes no handle, so a
32
+ command on a single-runner box can omit it.
33
+
34
+ Raises NoRunnerHomeError when no home answers to the handle or the box
35
+ has none, and AmbiguousRunnerHomeError when several do.
36
+ """
37
+ homes = self.all()
38
+ if handle is not None:
39
+ homes = [home for home in homes if home.answers_to(handle)]
40
+ if len(homes) == 0:
41
+ raise NoRunnerHomeError(f"No runner home here answers to '{handle}'.")
42
+ if len(homes) == 0:
43
+ raise NoRunnerHomeError
44
+ if len(homes) > 1:
45
+ raise AmbiguousRunnerHomeError(sorted(home.handle() for home in homes))
46
+ return homes[0]
47
+
48
+ def named(self, name: str) -> "RegisteredRunner | None":
49
+ """The home holding this runner name, with what it holds.
50
+
51
+ A home whose file cannot be read carries a name nothing can match, so
52
+ it is absent here and the server's name uniqueness is what refuses the
53
+ second registration.
54
+ """
55
+ for home in self.all():
56
+ stored = home.load()
57
+ if stored is not None and stored.name == name:
58
+ return RegisteredRunner(home, stored)
59
+ return None
60
+
61
+ def for_runner(self, runner_id: UUID) -> "RunnerHomeClient":
62
+ """The home for this runner id, whether or not it exists yet."""
63
+ return RunnerHomeClient(self._root / str(runner_id) / HOME_FILE_NAME)
64
+
65
+ def all(self) -> list["RunnerHomeClient"]:
66
+ """Every home on this box, by id.
67
+
68
+ A directory under the root without the file is not a home: an
69
+ interrupted registration leaves one, and it holds no credential.
70
+ """
71
+ if not self._root.is_dir():
72
+ return []
73
+ return [
74
+ RunnerHomeClient(entry / HOME_FILE_NAME)
75
+ for entry in sorted(self._root.iterdir())
76
+ if (entry / HOME_FILE_NAME).is_file()
77
+ ]
78
+
79
+ def handles(self) -> list[str]:
80
+ """What the homes on this box answer to, alphabetically."""
81
+ return sorted(home.handle() for home in self.all())
82
+
83
+
84
+ class RunnerHomeClient:
85
+ """One runner home, read and written as a whole.
86
+
87
+ The file carries the minted token, so it is written owner-readable and
88
+ replaced rather than edited in place. Registration is the only writer.
89
+ """
90
+
91
+ def __init__(self, path: Path):
92
+ self._path = path
93
+
94
+ @property
95
+ def path(self) -> Path:
96
+ return self._path
97
+
98
+ @property
99
+ def runner_id(self) -> str:
100
+ """The id keying this home, which is the directory it sits in."""
101
+ return self._path.parent.name
102
+
103
+ def exists(self) -> bool:
104
+ return self._path.is_file()
105
+
106
+ def answers_to(self, handle: str) -> bool:
107
+ """Whether `--runner <handle>` picks this home.
108
+
109
+ The id matches on a leading fragment, so an operator types enough of it
110
+ to be unambiguous rather than the whole of it. A name the server has
111
+ since changed no longer matches, which is what the id is here for.
112
+ """
113
+ if len(handle) == 0:
114
+ return False
115
+ if self.runner_id.startswith(handle.lower()):
116
+ return True
117
+ stored = self.load()
118
+ return stored is not None and stored.name == handle
119
+
120
+ def handle(self) -> str:
121
+ """What this home answers to, for a message that lists several.
122
+
123
+ A home whose file cannot be read has no name to give, so it gives the
124
+ id, which is the only handle left that reaches it.
125
+ """
126
+ stored = self.load()
127
+ if stored is None:
128
+ return self.runner_id
129
+ return stored.name
130
+
131
+ def load(self) -> RunnerHome | None:
132
+ try:
133
+ raw = self._path.read_text()
134
+ except FileNotFoundError:
135
+ return None
136
+ try:
137
+ return RunnerHome.model_validate_json(raw)
138
+ except ValidationError:
139
+ logger.warning("runner home at %s is malformed; ignoring", self._path)
140
+ return None
141
+
142
+ def save(self, home: RunnerHome) -> None:
143
+ # Owner-only: a homes root outside a user's own tree is otherwise
144
+ # listable by everyone on the box, and the token sits in here.
145
+ self._path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
146
+ write_private_file(self._path, home.model_dump_json())
147
+
148
+ def clear(self) -> None:
149
+ """Remove the home, file and directory both.
150
+
151
+ A home is its file, so blanking the token would leave a directory no
152
+ command can complete: nothing installs a token into an existing home,
153
+ and registering over it writes a new one under the new id anyway.
154
+ """
155
+ self._path.unlink(missing_ok=True)
156
+ parent = self._path.parent
157
+ if parent.is_dir() and len(list(parent.iterdir())) == 0:
158
+ parent.rmdir()
159
+
160
+
161
+ class RegisteredRunner(NamedTuple):
162
+ """A home on this box and the document it holds."""
163
+
164
+ home: RunnerHomeClient
165
+ stored: RunnerHome
@@ -0,0 +1,99 @@
1
+ """WorkOS CLI Auth (OAuth 2.0 Device Authorization Grant, RFC 8628).
2
+
3
+ Docs: https://workos.com/docs/authkit/cli-auth
4
+ RFC 8628: https://datatracker.ietf.org/doc/html/rfc8628
5
+ """
6
+
7
+ import logging
8
+ from collections.abc import Callable
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from manifold_cli.models.auth import (
14
+ CliConfig,
15
+ DeviceAuthorization,
16
+ PollPending,
17
+ TokenResponse,
18
+ )
19
+ from manifold_cli.models.errors import WorkOSAuthError
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
24
+ REFRESH_TOKEN_GRANT = "refresh_token"
25
+
26
+
27
+ class WorkOSDeviceClient:
28
+ def __init__(self, config_provider: Callable[[], CliConfig]):
29
+ self._config_provider = config_provider
30
+ self._cached_config: CliConfig | None = None
31
+ self._client = httpx.Client()
32
+
33
+ def _config(self) -> CliConfig:
34
+ if self._cached_config is None:
35
+ self._cached_config = self._config_provider()
36
+ return self._cached_config
37
+
38
+ def start_device_authorization(self) -> DeviceAuthorization:
39
+ config = self._config()
40
+ response = self._client.post(
41
+ config.authorization_endpoint,
42
+ data={"client_id": config.workos_client_id},
43
+ )
44
+ if response.is_error:
45
+ body = _safe_json(response)
46
+ raise WorkOSAuthError(
47
+ error=body.get("error", "authorization_request_failed"),
48
+ description=body.get("error_description") or response.text,
49
+ )
50
+ return DeviceAuthorization.model_validate(response.json())
51
+
52
+ def poll_token(self, device_code: str) -> TokenResponse | PollPending:
53
+ config = self._config()
54
+ response = self._client.post(
55
+ config.token_endpoint,
56
+ data={
57
+ "grant_type": DEVICE_CODE_GRANT,
58
+ "device_code": device_code,
59
+ "client_id": config.workos_client_id,
60
+ },
61
+ )
62
+ if response.is_success:
63
+ return TokenResponse.model_validate(response.json())
64
+ body = _safe_json(response)
65
+ error = body.get("error", "")
66
+ if error == PollPending.PENDING.value:
67
+ return PollPending.PENDING
68
+ if error == PollPending.SLOW_DOWN.value:
69
+ return PollPending.SLOW_DOWN
70
+ raise WorkOSAuthError(
71
+ error=error or "token_request_failed",
72
+ description=body.get("error_description"),
73
+ )
74
+
75
+ def refresh(self, refresh_token: str) -> TokenResponse:
76
+ config = self._config()
77
+ response = self._client.post(
78
+ config.token_endpoint,
79
+ data={
80
+ "grant_type": REFRESH_TOKEN_GRANT,
81
+ "refresh_token": refresh_token,
82
+ "client_id": config.workos_client_id,
83
+ },
84
+ )
85
+ if response.is_success:
86
+ return TokenResponse.model_validate(response.json())
87
+ body = _safe_json(response)
88
+ raise WorkOSAuthError(
89
+ error=body.get("error", "refresh_failed"),
90
+ description=body.get("error_description"),
91
+ )
92
+
93
+
94
+ def _safe_json(response: httpx.Response) -> dict[str, Any]:
95
+ try:
96
+ data = response.json()
97
+ except ValueError:
98
+ return {}
99
+ return data if isinstance(data, dict) else {}
File without changes
@@ -0,0 +1,101 @@
1
+ import typer
2
+ from rich.console import Console
3
+ from rich.markup import escape
4
+
5
+ from manifold_cli.commands.organization import (
6
+ NO_ORGANIZATION_MESSAGE,
7
+ ORGANIZATION_CALL_ERRORS,
8
+ exit_for_call_error,
9
+ )
10
+ from manifold_cli.dependencies import Dependencies
11
+ from manifold_cli.models.auth import AuthState, DeviceAuthorization
12
+ from manifold_cli.models.errors import ManifoldApiError, WorkOSAuthError
13
+
14
+ SKILLS_HINT = (
15
+ "Run [bold]npx skills add bifrostai/manifold-skills --all[/bold] "
16
+ "to install agent skills."
17
+ )
18
+
19
+
20
+ def register(app: typer.Typer, deps: Dependencies) -> None:
21
+ console = Console()
22
+ auth_app = typer.Typer(
23
+ name="auth",
24
+ help="Manage Manifold authentication.",
25
+ no_args_is_help=True,
26
+ )
27
+ app.add_typer(auth_app)
28
+
29
+ @auth_app.command()
30
+ def login() -> None:
31
+ """Log in to Manifold via WorkOS device authorization."""
32
+
33
+ def show(device_auth: DeviceAuthorization) -> None:
34
+ console.print(
35
+ f"Visit [bold]{escape(device_auth.verification_uri_complete)}[/bold]\n"
36
+ f"and confirm code [bold]{escape(device_auth.user_code)}[/bold]."
37
+ )
38
+
39
+ try:
40
+ deps.auth_service.login(on_device_code=show)
41
+ except (ManifoldApiError, WorkOSAuthError) as exc:
42
+ console.print(f"[red]Login failed:[/red] {escape(str(exc))}")
43
+ raise typer.Exit(1) from exc
44
+
45
+ console.print("[green]Login successful.[/green]")
46
+ if _report_organizations(console, deps):
47
+ console.print(SKILLS_HINT)
48
+
49
+ @auth_app.command()
50
+ def logout() -> None:
51
+ """Forget local credentials."""
52
+ deps.auth_service.logout()
53
+ console.print("Logged out.")
54
+
55
+ @auth_app.command()
56
+ def status() -> None:
57
+ """Show whether you are logged in to Manifold."""
58
+ try:
59
+ result = deps.auth_service.status()
60
+ except ORGANIZATION_CALL_ERRORS as exc:
61
+ raise exit_for_call_error(console, exc) from exc
62
+
63
+ match result.state:
64
+ case AuthState.LOGGED_OUT:
65
+ console.print(
66
+ "Not logged in. Run `manifold auth login` to authenticate."
67
+ )
68
+ case AuthState.SESSION_EXPIRED:
69
+ console.print(
70
+ "[yellow]Session expired.[/yellow] "
71
+ "Run `manifold auth login` to re-authenticate."
72
+ )
73
+ case AuthState.LOGGED_IN:
74
+ assert result.user is not None
75
+ console.print(
76
+ f"[green]Logged in as[/green] "
77
+ f"[bold]{escape(result.user.email)}[/bold]"
78
+ )
79
+
80
+
81
+ def _report_organizations(console: Console, deps: Dependencies) -> bool:
82
+ """Print the caller's organizations and report whether one was found.
83
+
84
+ Best-effort login epilogue: expected network, API, auth, and
85
+ response-shape failures are downgraded to a warning so they never fail the
86
+ login. A bug in this function itself still surfaces.
87
+ """
88
+ try:
89
+ organizations = deps.organization_service.list()
90
+ except ORGANIZATION_CALL_ERRORS as exc:
91
+ console.print(
92
+ f"[yellow]Could not check your organizations:[/yellow] {escape(str(exc))}"
93
+ )
94
+ return False
95
+
96
+ if not organizations:
97
+ console.print(NO_ORGANIZATION_MESSAGE)
98
+ return False
99
+ slugs = ", ".join(organization.slug for organization in organizations)
100
+ console.print(f"Organization: [bold]{slugs}[/bold]")
101
+ return True