amina-cli 0.3.0__tar.gz → 0.4.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.
- {amina_cli-0.3.0 → amina_cli-0.4.1}/PKG-INFO +1 -1
- {amina_cli-0.3.0 → amina_cli-0.4.1}/pyproject.toml +1 -1
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/__init__.py +1 -1
- amina_cli-0.4.1/src/amina_cli/auth.py +558 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/client.py +237 -26
- amina_cli-0.4.1/src/amina_cli/commands/jobs_cmd.py +1045 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/__init__.py +150 -21
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/registry.py +11 -0
- amina_cli-0.3.0/src/amina_cli/auth.py +0 -369
- amina_cli-0.3.0/src/amina_cli/commands/jobs_cmd.py +0 -515
- {amina_cli-0.3.0 → amina_cli-0.4.1}/.gitignore +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/LICENSE +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/README.md +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/__init__.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/auth_cmd.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/run_cmd.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/__init__.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/docs/hydrophobicity.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/docs/mmseqs2_cluster.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/docs/residue_accessibility.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/docs/rmsd.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/docs/sasa.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/docs/simple_rmsd.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/docs/surface_charge.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/docs/usalign.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/hydrophobicity.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/mmseqs2_cluster.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/residue_accessibility.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/rmsd.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/sasa.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/simple_rmsd.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/surface_charge.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/usalign.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/__init__.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/docs/esm_if1.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/docs/protein_mc.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/docs/proteinmpnn.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/docs/rfdiffusion.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/esm_if1.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/protein_mc.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/proteinmpnn.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/rfdiffusion.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/display.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/doccard.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/__init__.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/boltz2.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/docs/boltz2.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/docs/esmfold.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/docs/openfold3.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/docs/protenix.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/esmfold.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/openfold3.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/protenix.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/__init__.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/autodock_vina.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/diffdock.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/dockq.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/autodock_vina.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/diffdock.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/dockq.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/emngly.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/glycosylation_ensemble.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/interface_identifier.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/isoglyp.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/lmngly.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/p2rank.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/pesto.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/emngly.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/glycosylation_ensemble.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/interface_identifier.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/isoglyp.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/lmngly.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/p2rank.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/pesto.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/properties/__init__.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/properties/aminosol.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/properties/docs/aminosol.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/properties/docs/esm1v.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/properties/docs/esm2_embedding.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/properties/esm1v.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/properties/esm2_embedding.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/__init__.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/activesite_verifier.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/chain_select.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/distance_calculator.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/activesite_verifier.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/chain_select.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/distance_calculator.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/maxit_convert.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/mol_size_calculator.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/obabel_convert.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/pdb_bfactor_overwrite.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/pdb_cleaner.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/pdb_quality_assessment.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/pdb_to_fasta.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/protein_relaxer.yaml +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/maxit_convert.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/mol_size_calculator.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/obabel_convert.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/pdb_bfactor_overwrite.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/pdb_cleaner.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/pdb_quality_assessment.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/pdb_to_fasta.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/protein_relaxer.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools_cmd.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/main.py +0 -0
- {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/storage.py +0 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local credential storage for Amina CLI.
|
|
3
|
+
|
|
4
|
+
This module handles storing and retrieving API keys locally.
|
|
5
|
+
Validation happens server-side when the key is used.
|
|
6
|
+
|
|
7
|
+
Credentials are stored in ~/.amina/credentials.json
|
|
8
|
+
|
|
9
|
+
This is the same pattern used by:
|
|
10
|
+
- OpenAI CLI: stores key in ~/.openai
|
|
11
|
+
- AWS CLI: stores in ~/.aws/credentials
|
|
12
|
+
- Stripe CLI: stores in ~/.config/stripe
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import fcntl
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Iterator, Optional
|
|
21
|
+
|
|
22
|
+
# Configuration directory and files
|
|
23
|
+
CONFIG_DIR = Path.home() / ".amina"
|
|
24
|
+
CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
|
|
25
|
+
|
|
26
|
+
# API key format validation
|
|
27
|
+
API_KEY_PREFIX = "ami_"
|
|
28
|
+
API_KEY_MIN_LENGTH = 36 # ami_ + 32 hex chars
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthError(Exception):
|
|
32
|
+
"""Raised when authentication fails or credentials are missing."""
|
|
33
|
+
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_key_format(key: str) -> bool:
|
|
38
|
+
"""
|
|
39
|
+
Validate that a key has the correct format.
|
|
40
|
+
|
|
41
|
+
Only checks format, NOT validity. Server validates when key is used.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
key: The API key to validate
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
True if format is valid
|
|
48
|
+
"""
|
|
49
|
+
if not key:
|
|
50
|
+
return False
|
|
51
|
+
if not key.startswith(API_KEY_PREFIX):
|
|
52
|
+
return False
|
|
53
|
+
if len(key) < API_KEY_MIN_LENGTH:
|
|
54
|
+
return False
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def set_api_key(key: str) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Store an API key locally.
|
|
61
|
+
|
|
62
|
+
Format is validated but the key is NOT verified against the server.
|
|
63
|
+
Verification happens when the key is used for the first time.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
key: The API key to store
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ValueError: If key format is invalid
|
|
70
|
+
"""
|
|
71
|
+
if not validate_key_format(key):
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"Invalid API key format. Must start with '{API_KEY_PREFIX}' "
|
|
74
|
+
f"and be at least {API_KEY_MIN_LENGTH} characters."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
save_credentials({"api_key": key})
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def save_credentials(data: dict) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Save credentials to the config file.
|
|
83
|
+
|
|
84
|
+
Follows the same security pattern as OpenAI CLI:
|
|
85
|
+
- Directory: 0o700 (owner only)
|
|
86
|
+
- File: 0o600 (owner read/write only)
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
data: Dictionary of credentials to save
|
|
90
|
+
"""
|
|
91
|
+
import os
|
|
92
|
+
|
|
93
|
+
# Create config directory with restrictive permissions (owner only)
|
|
94
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
95
|
+
|
|
96
|
+
# Write credentials file atomically with secure permissions
|
|
97
|
+
# This prevents a race condition where the file briefly exists with wrong permissions
|
|
98
|
+
content = json.dumps(data, indent=2)
|
|
99
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
100
|
+
with os.fdopen(os.open(CREDENTIALS_FILE, flags, 0o600), "w") as f:
|
|
101
|
+
f.write(content)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_credentials() -> dict:
|
|
105
|
+
"""
|
|
106
|
+
Load stored credentials.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Dictionary with 'api_key' and any other stored data
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
AuthError: If not authenticated
|
|
113
|
+
"""
|
|
114
|
+
if not CREDENTIALS_FILE.exists():
|
|
115
|
+
raise AuthError(
|
|
116
|
+
"Not authenticated. Run: amina auth set-key <your_api_key>\n"
|
|
117
|
+
"Get an API key at: https://app.aminoanalytica.com/settings/api"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
data = json.loads(CREDENTIALS_FILE.read_text())
|
|
122
|
+
if not data.get("api_key"):
|
|
123
|
+
raise AuthError("Credentials file exists but no API key found.")
|
|
124
|
+
return data
|
|
125
|
+
except json.JSONDecodeError:
|
|
126
|
+
raise AuthError("Credentials file is corrupted. Run: amina auth set-key <key>")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_api_key() -> str:
|
|
130
|
+
"""
|
|
131
|
+
Get the stored API key.
|
|
132
|
+
|
|
133
|
+
Convenience wrapper around get_credentials().
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
The stored API key
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
AuthError: If not authenticated
|
|
140
|
+
"""
|
|
141
|
+
return get_credentials()["api_key"]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def clear_credentials() -> None:
|
|
145
|
+
"""
|
|
146
|
+
Remove all stored credentials (logout).
|
|
147
|
+
|
|
148
|
+
Idempotent - safe to call even if not authenticated.
|
|
149
|
+
"""
|
|
150
|
+
if CREDENTIALS_FILE.exists():
|
|
151
|
+
CREDENTIALS_FILE.unlink()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_key_display(key: str) -> str:
|
|
155
|
+
"""
|
|
156
|
+
Get a safe display version of an API key.
|
|
157
|
+
|
|
158
|
+
Shows prefix and first few chars, hides the rest.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
key: The full API key
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Safe display string like "ami_7f3k..."
|
|
165
|
+
"""
|
|
166
|
+
if len(key) > 12:
|
|
167
|
+
return f"{key[:12]}..."
|
|
168
|
+
return key
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def is_authenticated() -> bool:
|
|
172
|
+
"""
|
|
173
|
+
Check if credentials are stored.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
True if credentials file exists with an API key
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
get_credentials()
|
|
180
|
+
return True
|
|
181
|
+
except AuthError:
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_session_id() -> Optional[str]:
|
|
186
|
+
"""
|
|
187
|
+
Get the linked session/conversation ID if set.
|
|
188
|
+
|
|
189
|
+
When a session ID is set, CLI outputs will be linked to that
|
|
190
|
+
web conversation for visibility in the dashboard.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Session ID or None if not linked
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
data = get_credentials()
|
|
197
|
+
return data.get("session_id")
|
|
198
|
+
except AuthError:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def set_session_id(session_id: Optional[str]) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Link CLI outputs to a web conversation.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
session_id: The conversation ID to link, or None to unlink
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
data = get_credentials()
|
|
211
|
+
except AuthError:
|
|
212
|
+
raise AuthError("Must be authenticated before linking a session.")
|
|
213
|
+
|
|
214
|
+
if session_id:
|
|
215
|
+
data["session_id"] = session_id
|
|
216
|
+
else:
|
|
217
|
+
data.pop("session_id", None)
|
|
218
|
+
|
|
219
|
+
save_credentials(data)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
223
|
+
# JOB HISTORY STORAGE
|
|
224
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
225
|
+
|
|
226
|
+
JOBS_FILE = CONFIG_DIR / "jobs.json"
|
|
227
|
+
# Lock path is derived from JOBS_FILE inside _with_jobs_lock() so tests that
|
|
228
|
+
# patch JOBS_FILE don't have to patch a second constant.
|
|
229
|
+
MAX_JOB_HISTORY = 10_000 # Keep last N terminal jobs; non-terminal entries are
|
|
230
|
+
# never evicted regardless of cap (see save_job).
|
|
231
|
+
|
|
232
|
+
# Statuses that must NEVER be evicted by trim. Losing a queued/running job from
|
|
233
|
+
# the local cache means losing the only client-side handle to wait on it.
|
|
234
|
+
NON_TERMINAL_STATUSES = frozenset({"submitted", "queued", "running", "starting", "pending", "completing"})
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@contextlib.contextmanager
|
|
238
|
+
def _with_jobs_lock() -> Iterator[None]:
|
|
239
|
+
"""Hold an exclusive advisory lock on jobs.json mutations.
|
|
240
|
+
|
|
241
|
+
Without this, concurrent CLI processes (parallel `--background` submits,
|
|
242
|
+
parallel `jobs list` refreshes) race read-modify-write on jobs.json and
|
|
243
|
+
silently lose entries. The lock lives on a sibling `.lock` file so reads
|
|
244
|
+
can still proceed without blocking.
|
|
245
|
+
|
|
246
|
+
Both the parent dir and lock path are derived from JOBS_FILE on each call
|
|
247
|
+
so that tests patching JOBS_FILE (and prod paths via env in future) do not
|
|
248
|
+
have to also patch a separate LOCK_FILE constant.
|
|
249
|
+
"""
|
|
250
|
+
JOBS_FILE.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
251
|
+
lock_path = JOBS_FILE.with_name("jobs.lock")
|
|
252
|
+
fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o600)
|
|
253
|
+
try:
|
|
254
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
255
|
+
try:
|
|
256
|
+
yield
|
|
257
|
+
finally:
|
|
258
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
259
|
+
finally:
|
|
260
|
+
os.close(fd)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _atomic_write_jobs(content: str) -> None:
|
|
264
|
+
"""Write jobs.json via tmp + rename so concurrent readers never see a
|
|
265
|
+
partially-truncated file (the prior O_TRUNC + write was non-atomic)."""
|
|
266
|
+
tmp_path = JOBS_FILE.with_suffix(JOBS_FILE.suffix + ".tmp")
|
|
267
|
+
fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
268
|
+
with os.fdopen(fd, "w") as f:
|
|
269
|
+
f.write(content)
|
|
270
|
+
os.replace(tmp_path, JOBS_FILE)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _trim_with_non_terminal_protection(jobs: list[dict]) -> list[dict]:
|
|
274
|
+
"""Trim the cache to MAX_JOB_HISTORY entries, never evicting non-terminal
|
|
275
|
+
jobs. The cap is a soft cap for terminal entries only; an active job that
|
|
276
|
+
happens to be old must still be reachable for `jobs wait` / `jobs status`.
|
|
277
|
+
"""
|
|
278
|
+
if len(jobs) <= MAX_JOB_HISTORY:
|
|
279
|
+
return jobs
|
|
280
|
+
head = jobs[:MAX_JOB_HISTORY]
|
|
281
|
+
overflow = jobs[MAX_JOB_HISTORY:]
|
|
282
|
+
rescued = [j for j in overflow if j.get("status") in NON_TERMINAL_STATUSES]
|
|
283
|
+
return head + rescued
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def save_job(job_info: dict) -> None:
|
|
287
|
+
"""
|
|
288
|
+
Save a job to the local history.
|
|
289
|
+
|
|
290
|
+
Used by background job submission to track jobs for later status checks.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
job_info: Dict with job_id, call_id, user_id, tool_name, etc.
|
|
294
|
+
"""
|
|
295
|
+
from datetime import datetime
|
|
296
|
+
|
|
297
|
+
with _with_jobs_lock():
|
|
298
|
+
jobs = []
|
|
299
|
+
if JOBS_FILE.exists():
|
|
300
|
+
try:
|
|
301
|
+
jobs = json.loads(JOBS_FILE.read_text())
|
|
302
|
+
except (json.JSONDecodeError, IOError):
|
|
303
|
+
jobs = []
|
|
304
|
+
|
|
305
|
+
job_info["submitted_at"] = datetime.now().isoformat()
|
|
306
|
+
# Honour a caller-supplied status (e.g. "queued") instead of always
|
|
307
|
+
# stamping "submitted" — otherwise a queued job is silently mis-labelled
|
|
308
|
+
# in the local cache until the next server refresh.
|
|
309
|
+
job_info.setdefault("status", "submitted")
|
|
310
|
+
|
|
311
|
+
# Most-recent first.
|
|
312
|
+
jobs.insert(0, job_info)
|
|
313
|
+
jobs = _trim_with_non_terminal_protection(jobs)
|
|
314
|
+
|
|
315
|
+
_atomic_write_jobs(json.dumps(jobs, indent=2))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def get_job_history(limit: int = 20) -> list[dict]:
|
|
319
|
+
"""
|
|
320
|
+
Get recent jobs from local history.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
limit: Maximum number of jobs to return
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
List of job info dicts, most recent first
|
|
327
|
+
"""
|
|
328
|
+
if not JOBS_FILE.exists():
|
|
329
|
+
return []
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
jobs = json.loads(JOBS_FILE.read_text())
|
|
333
|
+
return jobs[:limit]
|
|
334
|
+
except (json.JSONDecodeError, IOError):
|
|
335
|
+
return []
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def get_job_info(job_id: str) -> Optional[dict]:
|
|
339
|
+
"""
|
|
340
|
+
Look up a job by ID (full or partial match).
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
job_id: Full job ID or prefix to match
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Job info dict or None if not found
|
|
347
|
+
"""
|
|
348
|
+
jobs = get_job_history(limit=MAX_JOB_HISTORY)
|
|
349
|
+
|
|
350
|
+
# Try exact match first
|
|
351
|
+
for job in jobs:
|
|
352
|
+
if job.get("job_id") == job_id:
|
|
353
|
+
return job
|
|
354
|
+
|
|
355
|
+
# Try prefix match
|
|
356
|
+
for job in jobs:
|
|
357
|
+
if job.get("job_id", "").startswith(job_id):
|
|
358
|
+
return job
|
|
359
|
+
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def update_job_status(job_id: str, status: str) -> None:
|
|
364
|
+
"""
|
|
365
|
+
Update the status of a job in local history.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
job_id: Job ID to update
|
|
369
|
+
status: New status (e.g., "completed", "failed")
|
|
370
|
+
"""
|
|
371
|
+
with _with_jobs_lock():
|
|
372
|
+
if not JOBS_FILE.exists():
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
jobs = json.loads(JOBS_FILE.read_text())
|
|
377
|
+
except (json.JSONDecodeError, IOError):
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
for job in jobs:
|
|
381
|
+
if job.get("job_id") == job_id:
|
|
382
|
+
job["status"] = status
|
|
383
|
+
break
|
|
384
|
+
|
|
385
|
+
_atomic_write_jobs(json.dumps(jobs, indent=2))
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def update_job_info(job_id: str, updates: dict) -> None:
|
|
389
|
+
"""
|
|
390
|
+
Merge fields into a saved job entry.
|
|
391
|
+
|
|
392
|
+
Used to persist a resolved call_id so subsequent polls skip the queue check.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
job_id: Job ID to update
|
|
396
|
+
updates: Dict of fields to merge (e.g., {"call_id": "fc-xxx"})
|
|
397
|
+
"""
|
|
398
|
+
with _with_jobs_lock():
|
|
399
|
+
if not JOBS_FILE.exists():
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
jobs = json.loads(JOBS_FILE.read_text())
|
|
404
|
+
except (json.JSONDecodeError, IOError):
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
for job in jobs:
|
|
408
|
+
if job.get("job_id") == job_id:
|
|
409
|
+
job.update(updates)
|
|
410
|
+
break
|
|
411
|
+
|
|
412
|
+
_atomic_write_jobs(json.dumps(jobs, indent=2))
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
416
|
+
# SERVER-SIDE REFRESH (writeback path for `amina jobs list`)
|
|
417
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
418
|
+
|
|
419
|
+
# Fields copied from a server job_tracker row into the local cache.
|
|
420
|
+
# Local-only bookkeeping (reserved_cost, compute_type, submitted_at) is preserved.
|
|
421
|
+
_SERVER_REFRESH_FIELDS = (
|
|
422
|
+
"status",
|
|
423
|
+
"message",
|
|
424
|
+
"runtime_seconds",
|
|
425
|
+
"cost_usd",
|
|
426
|
+
"completed_at",
|
|
427
|
+
"error_message",
|
|
428
|
+
"results",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _apply_server_fields_to(target: dict, server_row: dict, *, only_if_missing_call_id: bool) -> None:
|
|
433
|
+
"""Apply server-row fields to a local cache entry.
|
|
434
|
+
|
|
435
|
+
Single helper for both the insert and update paths so they don't drift.
|
|
436
|
+
The only behavioural difference between the two callsites is whether
|
|
437
|
+
``call_id`` should be overwritten (insert: always; update: only fill if
|
|
438
|
+
the local entry hasn't learned one yet).
|
|
439
|
+
"""
|
|
440
|
+
metadata = server_row.get("metadata") or {}
|
|
441
|
+
server_call_id = metadata.get("call_id")
|
|
442
|
+
if server_call_id and (not only_if_missing_call_id or not target.get("call_id")):
|
|
443
|
+
target["call_id"] = server_call_id
|
|
444
|
+
|
|
445
|
+
request_params = server_row.get("request_params") or {}
|
|
446
|
+
if isinstance(request_params, dict):
|
|
447
|
+
if request_params.get("job_name"):
|
|
448
|
+
target["job_name"] = request_params["job_name"]
|
|
449
|
+
if request_params:
|
|
450
|
+
target["request_params"] = request_params
|
|
451
|
+
|
|
452
|
+
for field in _SERVER_REFRESH_FIELDS:
|
|
453
|
+
if server_row.get(field) is None:
|
|
454
|
+
continue
|
|
455
|
+
# Cancelled is sticky on refresh too. If the gateway hadn't yet
|
|
456
|
+
# applied its own cancel-stickiness guard before this row was read,
|
|
457
|
+
# don't let a stale server "failed" overwrite the local "cancelled".
|
|
458
|
+
if field == "status" and target.get("status") == "cancelled" and server_row[field] == "failed":
|
|
459
|
+
continue
|
|
460
|
+
target[field] = server_row[field]
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _server_row_to_local_entry(server_row: dict) -> dict:
|
|
464
|
+
"""Project a job_tracker row into the local-cache shape."""
|
|
465
|
+
from datetime import datetime
|
|
466
|
+
|
|
467
|
+
entry: dict = {
|
|
468
|
+
"job_id": server_row.get("job_id", ""),
|
|
469
|
+
"user_id": server_row.get("user_id", ""),
|
|
470
|
+
"tool_name": server_row.get("tool_name", ""),
|
|
471
|
+
"compute_type": server_row.get("gpu_type") or "cpu",
|
|
472
|
+
"submitted_at": server_row.get("created_at") or datetime.now().isoformat(),
|
|
473
|
+
"status": server_row.get("status", "submitted"),
|
|
474
|
+
}
|
|
475
|
+
_apply_server_fields_to(entry, server_row, only_if_missing_call_id=False)
|
|
476
|
+
return entry
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _meta_path() -> Path:
|
|
480
|
+
"""Sibling file for jobs.json metadata (last_refresh_at, etc.).
|
|
481
|
+
|
|
482
|
+
Kept out of jobs.json itself so the file shape stays a pure list and any
|
|
483
|
+
downstream `jq` consumer keeps working unchanged.
|
|
484
|
+
"""
|
|
485
|
+
return JOBS_FILE.with_name("jobs.meta.json")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _write_meta(meta: dict) -> None:
|
|
489
|
+
"""Atomic write for the meta sidecar."""
|
|
490
|
+
p = _meta_path()
|
|
491
|
+
tmp = p.with_suffix(p.suffix + ".tmp")
|
|
492
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
493
|
+
with os.fdopen(fd, "w") as f:
|
|
494
|
+
f.write(json.dumps(meta, indent=2, default=str))
|
|
495
|
+
os.replace(tmp, p)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def read_jobs_meta() -> dict:
|
|
499
|
+
"""Read the jobs metadata sidecar (last_refresh_at, total_unfiltered_count, ...).
|
|
500
|
+
|
|
501
|
+
Returns ``{}`` if the file is missing or unreadable.
|
|
502
|
+
"""
|
|
503
|
+
p = _meta_path()
|
|
504
|
+
if not p.exists():
|
|
505
|
+
return {}
|
|
506
|
+
try:
|
|
507
|
+
return json.loads(p.read_text())
|
|
508
|
+
except (json.JSONDecodeError, IOError):
|
|
509
|
+
return {}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def merge_server_jobs(server_jobs: list[dict], total_unfiltered_count: Optional[int] = None) -> None:
|
|
513
|
+
"""
|
|
514
|
+
Merge authoritative server rows into the local job cache.
|
|
515
|
+
|
|
516
|
+
For each server row: update the matching local entry's status/metadata
|
|
517
|
+
fields in place, or insert a new local entry if the job is unknown locally
|
|
518
|
+
(e.g., evicted by the FIFO, or submitted from another machine). After the
|
|
519
|
+
merge, writes ``last_refresh_at`` (and optional ``total_unfiltered_count``)
|
|
520
|
+
to the meta sidecar so future telemetry / TTL throttling can act on it.
|
|
521
|
+
|
|
522
|
+
This is the writeback path for ``amina jobs list`` auto-refresh.
|
|
523
|
+
"""
|
|
524
|
+
from datetime import datetime
|
|
525
|
+
|
|
526
|
+
with _with_jobs_lock():
|
|
527
|
+
existing: list[dict] = []
|
|
528
|
+
if JOBS_FILE.exists():
|
|
529
|
+
try:
|
|
530
|
+
existing = json.loads(JOBS_FILE.read_text())
|
|
531
|
+
except (json.JSONDecodeError, IOError):
|
|
532
|
+
existing = []
|
|
533
|
+
|
|
534
|
+
by_job_id = {job.get("job_id"): job for job in existing if job.get("job_id")}
|
|
535
|
+
|
|
536
|
+
for row in server_jobs:
|
|
537
|
+
job_id = row.get("job_id")
|
|
538
|
+
if not job_id:
|
|
539
|
+
continue
|
|
540
|
+
local = by_job_id.get(job_id)
|
|
541
|
+
if local is None:
|
|
542
|
+
new_entry = _server_row_to_local_entry(row)
|
|
543
|
+
existing.append(new_entry)
|
|
544
|
+
by_job_id[job_id] = new_entry
|
|
545
|
+
continue
|
|
546
|
+
_apply_server_fields_to(local, row, only_if_missing_call_id=True)
|
|
547
|
+
|
|
548
|
+
# Re-sort newest-first by submitted_at so freshly-merged server rows and
|
|
549
|
+
# any pre-existing local entries interleave correctly. Without this the
|
|
550
|
+
# display order drifts to whatever insertion order produced.
|
|
551
|
+
existing.sort(key=lambda e: e.get("submitted_at") or "", reverse=True)
|
|
552
|
+
|
|
553
|
+
_atomic_write_jobs(json.dumps(existing, indent=2, default=str))
|
|
554
|
+
|
|
555
|
+
meta = {"last_refresh_at": datetime.now().isoformat()}
|
|
556
|
+
if total_unfiltered_count is not None:
|
|
557
|
+
meta["total_unfiltered_count"] = total_unfiltered_count
|
|
558
|
+
_write_meta(meta)
|