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.
Files changed (107) hide show
  1. {amina_cli-0.3.0 → amina_cli-0.4.1}/PKG-INFO +1 -1
  2. {amina_cli-0.3.0 → amina_cli-0.4.1}/pyproject.toml +1 -1
  3. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/__init__.py +1 -1
  4. amina_cli-0.4.1/src/amina_cli/auth.py +558 -0
  5. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/client.py +237 -26
  6. amina_cli-0.4.1/src/amina_cli/commands/jobs_cmd.py +1045 -0
  7. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/__init__.py +150 -21
  8. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/registry.py +11 -0
  9. amina_cli-0.3.0/src/amina_cli/auth.py +0 -369
  10. amina_cli-0.3.0/src/amina_cli/commands/jobs_cmd.py +0 -515
  11. {amina_cli-0.3.0 → amina_cli-0.4.1}/.gitignore +0 -0
  12. {amina_cli-0.3.0 → amina_cli-0.4.1}/LICENSE +0 -0
  13. {amina_cli-0.3.0 → amina_cli-0.4.1}/README.md +0 -0
  14. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/__init__.py +0 -0
  15. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/auth_cmd.py +0 -0
  16. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/run_cmd.py +0 -0
  17. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/__init__.py +0 -0
  18. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/docs/hydrophobicity.yaml +0 -0
  19. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/docs/mmseqs2_cluster.yaml +0 -0
  20. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/docs/residue_accessibility.yaml +0 -0
  21. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/docs/rmsd.yaml +0 -0
  22. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/docs/sasa.yaml +0 -0
  23. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/docs/simple_rmsd.yaml +0 -0
  24. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/docs/surface_charge.yaml +0 -0
  25. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/docs/usalign.yaml +0 -0
  26. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/hydrophobicity.py +0 -0
  27. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/mmseqs2_cluster.py +0 -0
  28. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/residue_accessibility.py +0 -0
  29. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/rmsd.py +0 -0
  30. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/sasa.py +0 -0
  31. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/simple_rmsd.py +0 -0
  32. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/surface_charge.py +0 -0
  33. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/analysis/usalign.py +0 -0
  34. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/__init__.py +0 -0
  35. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/docs/esm_if1.yaml +0 -0
  36. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/docs/protein_mc.yaml +0 -0
  37. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/docs/proteinmpnn.yaml +0 -0
  38. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/docs/rfdiffusion.yaml +0 -0
  39. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/esm_if1.py +0 -0
  40. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/protein_mc.py +0 -0
  41. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/proteinmpnn.py +0 -0
  42. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/design/rfdiffusion.py +0 -0
  43. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/display.py +0 -0
  44. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/doccard.py +0 -0
  45. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/__init__.py +0 -0
  46. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/boltz2.py +0 -0
  47. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/docs/boltz2.yaml +0 -0
  48. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/docs/esmfold.yaml +0 -0
  49. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/docs/openfold3.yaml +0 -0
  50. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/docs/protenix.yaml +0 -0
  51. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/esmfold.py +0 -0
  52. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/openfold3.py +0 -0
  53. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/folding/protenix.py +0 -0
  54. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/__init__.py +0 -0
  55. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/autodock_vina.py +0 -0
  56. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/diffdock.py +0 -0
  57. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/dockq.py +0 -0
  58. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/autodock_vina.yaml +0 -0
  59. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/diffdock.yaml +0 -0
  60. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/dockq.yaml +0 -0
  61. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/emngly.yaml +0 -0
  62. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/glycosylation_ensemble.yaml +0 -0
  63. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/interface_identifier.yaml +0 -0
  64. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/isoglyp.yaml +0 -0
  65. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/lmngly.yaml +0 -0
  66. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/p2rank.yaml +0 -0
  67. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/docs/pesto.yaml +0 -0
  68. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/emngly.py +0 -0
  69. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/glycosylation_ensemble.py +0 -0
  70. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/interface_identifier.py +0 -0
  71. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/isoglyp.py +0 -0
  72. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/lmngly.py +0 -0
  73. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/p2rank.py +0 -0
  74. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/interactions/pesto.py +0 -0
  75. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/properties/__init__.py +0 -0
  76. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/properties/aminosol.py +0 -0
  77. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/properties/docs/aminosol.yaml +0 -0
  78. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/properties/docs/esm1v.yaml +0 -0
  79. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/properties/docs/esm2_embedding.yaml +0 -0
  80. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/properties/esm1v.py +0 -0
  81. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/properties/esm2_embedding.py +0 -0
  82. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/__init__.py +0 -0
  83. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/activesite_verifier.py +0 -0
  84. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/chain_select.py +0 -0
  85. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/distance_calculator.py +0 -0
  86. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/activesite_verifier.yaml +0 -0
  87. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/chain_select.yaml +0 -0
  88. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/distance_calculator.yaml +0 -0
  89. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/maxit_convert.yaml +0 -0
  90. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/mol_size_calculator.yaml +0 -0
  91. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/obabel_convert.yaml +0 -0
  92. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/pdb_bfactor_overwrite.yaml +0 -0
  93. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/pdb_cleaner.yaml +0 -0
  94. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/pdb_quality_assessment.yaml +0 -0
  95. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/pdb_to_fasta.yaml +0 -0
  96. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/docs/protein_relaxer.yaml +0 -0
  97. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/maxit_convert.py +0 -0
  98. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/mol_size_calculator.py +0 -0
  99. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/obabel_convert.py +0 -0
  100. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/pdb_bfactor_overwrite.py +0 -0
  101. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/pdb_cleaner.py +0 -0
  102. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/pdb_quality_assessment.py +0 -0
  103. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/pdb_to_fasta.py +0 -0
  104. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools/utilities/protein_relaxer.py +0 -0
  105. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/commands/tools_cmd.py +0 -0
  106. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/main.py +0 -0
  107. {amina_cli-0.3.0 → amina_cli-0.4.1}/src/amina_cli/storage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amina-cli
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: CLI for AminoAnalytica protein engineering platform
5
5
  Project-URL: Homepage, https://aminoanalytica.com
6
6
  Project-URL: Documentation, https://docs.aminoanalytica.com
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "amina-cli"
3
- version = "0.3.0"
3
+ version = "0.4.1"
4
4
  description = "CLI for AminoAnalytica protein engineering platform"
5
5
  readme = "README.md"
6
6
  license = {text = "Apache-2.0"}
@@ -9,4 +9,4 @@ Quick start:
9
9
  amina run esmfold --sequence "MKFLILLFNILCLFPVLAADNH"
10
10
  """
11
11
 
12
- __version__ = "0.3.0"
12
+ __version__ = "0.4.1"
@@ -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)