flowstash-cli 0.12.0__tar.gz → 0.13.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 (50) hide show
  1. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/PKG-INFO +2 -2
  2. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/pyproject.toml +2 -2
  3. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/commands/apikey.py +50 -13
  4. flowstash_cli-0.13.1/src/flowstash/cli/commands/diagnostics.py +336 -0
  5. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/core/api_client.py +22 -1
  6. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/core/config.py +19 -5
  7. flowstash_cli-0.13.1/src/flowstash/cli/core/diagnostics.py +193 -0
  8. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/main.py +6 -0
  9. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/__init__.py +0 -0
  10. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/commands/__init__.py +0 -0
  11. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/commands/auth.py +0 -0
  12. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/commands/build.py +0 -0
  13. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/commands/client.py +0 -0
  14. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/commands/deploy.py +0 -0
  15. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/commands/project.py +0 -0
  16. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/commands/run.py +0 -0
  17. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/commands/webhook.py +0 -0
  18. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/core/__init__.py +0 -0
  19. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/core/auth_server.py +0 -0
  20. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/core/builder.py +0 -0
  21. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/core/docker_utils.py +0 -0
  22. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/core/patcher.py +0 -0
  23. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/AGENTS.md +0 -0
  24. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/README.md +0 -0
  25. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_.dockerignore +0 -0
  26. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_.flowstash +0 -0
  27. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_api_main.py +0 -0
  28. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_config/[env]/(backend-asyncio)/backend.yaml +0 -0
  29. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_config/[env]/(backend-dramatiq)/backend.yaml +0 -0
  30. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_config/[env]/(backend-managed)/backend.yaml +0 -0
  31. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_config/[env]/(observability-logfile)/observability.yaml +0 -0
  32. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_config/[env]/(observability-managed)/observability.yaml +0 -0
  33. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_config/shared/backend.yaml +0 -0
  34. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_config/shared/clients/demoClient.yaml +0 -0
  35. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_config/shared/clients.yaml +0 -0
  36. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_deployment/[env]/(backend-asyncio)/docker-compose.yaml +0 -0
  37. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_deployment/[env]/(backend-dramatiq)/docker-compose.yaml +0 -0
  38. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_deployment/shared/api.Dockerfile +0 -0
  39. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_deployment/shared/worker.Dockerfile +0 -0
  40. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_pyproject.toml +0 -0
  41. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_src/_api/__init__.py +0 -0
  42. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_src/_api/_routes/webhooks.py +0 -0
  43. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_src/_shared/__init__.py +0 -0
  44. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_src/_shared/clients/client.py +0 -0
  45. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_src/_shared/models/models.py +0 -0
  46. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_src/_shared/tasks/sharedTasks.py +0 -0
  47. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_src/_worker/__init__.py +0 -0
  48. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_src/_worker/tasks/tasks.py +0 -0
  49. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/templates/_worker_main.py +0 -0
  50. {flowstash_cli-0.12.0 → flowstash_cli-0.13.1}/src/flowstash/cli/ui/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: flowstash-cli
3
- Version: 0.12.0
3
+ Version: 0.13.1
4
4
  Summary: CLI for the flowstash Managed Platform
5
5
  License: MIT
6
6
  Author: juraj.bezdek@gmail.com
@@ -11,7 +11,7 @@ Classifier: Programming Language :: Python :: 3
11
11
  Classifier: Programming Language :: Python :: 3.11
12
12
  Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
- Requires-Dist: flowstash-runtime (>=0.12.0,<0.13.0)
14
+ Requires-Dist: flowstash-runtime (>=0.13.1,<0.14.0)
15
15
  Requires-Dist: httpx (>=0.27.0)
16
16
  Requires-Dist: keyring (>=25.0.0)
17
17
  Requires-Dist: libcst (>=1.1.0)
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "flowstash-cli"
3
- version = "0.12.0"
3
+ version = "0.13.1"
4
4
  description = "CLI for the flowstash Managed Platform"
5
5
  authors = [{name = "juraj.bezdek@gmail.com", email = "juraj.bezdek@gmail.com"}]
6
6
  license = "MIT"
7
7
  requires-python = ">=3.11"
8
8
  dependencies = [
9
- "flowstash-runtime>=0.12.0,<0.13.0",
9
+ "flowstash-runtime>=0.13.1,<0.14.0",
10
10
  "typer[all]>=0.12.0",
11
11
  "httpx>=0.27.0",
12
12
  "pyyaml>=6.0.1",
@@ -24,7 +24,7 @@ from rich.prompt import Prompt
24
24
  from rich.table import Table
25
25
 
26
26
  from ..core.api_client import APIClient
27
- from ..core.config import get_access_token, resolve_credentials
27
+ from ..core.config import get_access_token, resolve_credentials, find_project_root, load_project_config
28
28
 
29
29
  app = typer.Typer(
30
30
  name="api-keys",
@@ -33,9 +33,10 @@ app = typer.Typer(
33
33
  )
34
34
  console = Console()
35
35
 
36
- _VALID_SCOPES = {"observability:ingest", "admin"}
36
+ _VALID_SCOPES = {"observability:ingest", "observability:read", "admin"}
37
37
  _SCOPE_DESCRIPTIONS = {
38
38
  "observability:ingest": "Write-only ingestion (SDK / workers)",
39
+ "observability:read": "Read-only managed diagnostics (agents)",
39
40
  "admin": "Full management access (CI / automation)",
40
41
  }
41
42
 
@@ -49,7 +50,8 @@ def _format_ts(ts) -> str:
49
50
  return str(ts)
50
51
 
51
52
 
52
- def _find_project_root(start_path: Path = Path.cwd()) -> Optional[Path]:
53
+ def _find_project_root(start_path: Optional[Path] = None) -> Optional[Path]:
54
+ start_path = start_path or Path.cwd()
53
55
  for parent in [start_path] + list(start_path.parents):
54
56
  if (parent / ".flowstash").exists() or (parent / "pyproject.toml").exists():
55
57
  return parent
@@ -104,6 +106,8 @@ def _create_key(
104
106
  scope: Optional[str],
105
107
  env: Optional[str],
106
108
  user: Optional[str] = None,
109
+ project: Optional[str] = None,
110
+ write_env: Optional[str] = None,
107
111
  ):
108
112
  """Shared implementation for 'new' and 'create'."""
109
113
  token = resolve_credentials(user=user)
@@ -119,7 +123,7 @@ def _create_key(
119
123
  "Key scope:",
120
124
  choices=[
121
125
  questionary.Choice(f"{s} — {_SCOPE_DESCRIPTIONS[s]}", s)
122
- for s in _VALID_SCOPES
126
+ for s in sorted(_VALID_SCOPES - {"observability:read"})
123
127
  ],
124
128
  default="observability:ingest",
125
129
  ).ask()
@@ -132,6 +136,21 @@ def _create_key(
132
136
  )
133
137
  raise typer.Exit(code=1)
134
138
 
139
+ # Preserve legacy ingest/admin --env file setup without --project.
140
+ # Read keys require explicit scope and never write to a file implicitly.
141
+ restricted_env = env if project or scope == "observability:read" else None
142
+ if restricted_env and not project:
143
+ root = find_project_root()
144
+ try:
145
+ config = load_project_config(root, strict=True) if root else None
146
+ except ValueError:
147
+ console.print("[red]Malformed .flowstash configuration.[/red]")
148
+ raise typer.Exit(code=2)
149
+ project = config.project_id if config else None
150
+ if not project:
151
+ console.print("[red]An environment restriction requires --project or a linked project.[/red]")
152
+ raise typer.Exit(code=2)
153
+
135
154
  if scope == "admin" and env:
136
155
  console.print(
137
156
  "[yellow]Warning: writing an 'admin' key to .env is not recommended. "
@@ -140,7 +159,12 @@ def _create_key(
140
159
 
141
160
  async def _create():
142
161
  api = APIClient(token=token)
143
- return await api.post("/v1/api-keys", json={"label": label, "scopes": [scope]})
162
+ body = {"label": label, "scopes": [scope]}
163
+ if project:
164
+ body["project_id"] = project
165
+ if restricted_env:
166
+ body["environment"] = restricted_env
167
+ return await api.post("/v1/api-keys", json=body)
144
168
 
145
169
  try:
146
170
  result = asyncio.run(_create())
@@ -155,14 +179,19 @@ def _create_key(
155
179
  console.print(f" Key ID : [bold]{result['key_id']}[/bold]")
156
180
  console.print(f" Label : {label}")
157
181
  console.print(f" Scope : {scope}")
182
+ if project:
183
+ console.print(f" Project : {project}")
184
+ if restricted_env:
185
+ console.print(f" Env : {restricted_env}")
158
186
  console.print()
159
187
  console.print(
160
188
  "[bold yellow]⚠ Save this key — it will NOT be shown again:[/bold yellow]"
161
189
  )
162
190
  console.print(f"\n [bold cyan]{raw_key}[/bold cyan]\n")
163
191
 
164
- if env:
165
- _write_key_to_env(env, raw_key)
192
+ target_env = write_env or (env if scope != "observability:read" else None)
193
+ if target_env:
194
+ _write_key_to_env(target_env, raw_key)
166
195
 
167
196
 
168
197
  @app.command("new")
@@ -171,17 +200,19 @@ def apikey_new(
171
200
  None, "--label", "-l", help="Human-readable label for the key"
172
201
  ),
173
202
  scope: Optional[str] = typer.Option(
174
- None, "--scope", "-s", help="Scope: observability:ingest | admin"
203
+ None, "--scope", "-s", help="Scope: observability:ingest | observability:read | admin"
175
204
  ),
176
205
  env: Optional[str] = typer.Option(
177
- None, "--env", "-e", help="Write key to this environment's .env file"
206
+ None, "--env", "-e", help="Restrict read keys to an environment; legacy ingest/admin .env setup"
178
207
  ),
179
208
  user: Optional[str] = typer.Option(
180
209
  None, "--user", "-u", help="Account to use (default: project-linked or current)"
181
210
  ),
211
+ project: Optional[str] = typer.Option(None, "--project", help="Restrict key to a managed project"),
212
+ write_env: Optional[str] = typer.Option(None, "--write-env", help="Explicitly write key to an environment .env file"),
182
213
  ):
183
214
  """Create a new API key and optionally write it to an environment .env file."""
184
- _create_key(label=label, scope=scope, env=env, user=user)
215
+ _create_key(label=label, scope=scope, env=env, user=user, project=project, write_env=write_env)
185
216
 
186
217
 
187
218
  @app.command("create")
@@ -190,17 +221,19 @@ def apikey_create(
190
221
  None, "--label", "-l", help="Human-readable label for the key"
191
222
  ),
192
223
  scope: Optional[str] = typer.Option(
193
- None, "--scope", "-s", help="Scope: observability:ingest | admin"
224
+ None, "--scope", "-s", help="Scope: observability:ingest | observability:read | admin"
194
225
  ),
195
226
  env: Optional[str] = typer.Option(
196
- None, "--env", "-e", help="Write key to this environment's .env file"
227
+ None, "--env", "-e", help="Restrict read keys to an environment; legacy ingest/admin .env setup"
197
228
  ),
198
229
  user: Optional[str] = typer.Option(
199
230
  None, "--user", "-u", help="Account to use (default: project-linked or current)"
200
231
  ),
232
+ project: Optional[str] = typer.Option(None, "--project", help="Restrict key to a managed project"),
233
+ write_env: Optional[str] = typer.Option(None, "--write-env", help="Explicitly write key to an environment .env file"),
201
234
  ):
202
235
  """Create a new API key (alias for 'new')."""
203
- _create_key(label=label, scope=scope, env=env, user=user)
236
+ _create_key(label=label, scope=scope, env=env, user=user, project=project, write_env=write_env)
204
237
 
205
238
 
206
239
  @app.command("list")
@@ -235,6 +268,8 @@ def apikey_list(
235
268
  table.add_column("Label")
236
269
  table.add_column("Prefix")
237
270
  table.add_column("Scopes", style="cyan")
271
+ table.add_column("Project")
272
+ table.add_column("Environment")
238
273
  table.add_column("Created")
239
274
  table.add_column("Last Used")
240
275
 
@@ -244,6 +279,8 @@ def apikey_list(
244
279
  k["label"],
245
280
  k["key_prefix"],
246
281
  ", ".join(k.get("scopes", [])),
282
+ k.get("project_id") or "—",
283
+ k.get("environment") or "—",
247
284
  _format_ts(k.get("created_at")),
248
285
  _format_ts(k.get("last_used_at")),
249
286
  )
@@ -0,0 +1,336 @@
1
+ """Resource-specific read-only diagnostics. No payload hydration or page draining."""
2
+ from enum import Enum
3
+ from typing import Optional
4
+ import typer
5
+ from ..core.diagnostics import DiagnosticCommand, DiagnosticGroup, execute, resolve_context, query_options
6
+
7
+ class Order(str, Enum):
8
+ asc = "asc"
9
+ desc = "desc"
10
+
11
+ class EventType(str, Enum):
12
+ STARTED = "STARTED"
13
+ PROGRESS = "PROGRESS"
14
+ ENDED = "ENDED"
15
+ SCHEDULED = "SCHEDULED"
16
+
17
+ class BuildSeverity(str, Enum):
18
+ DEFAULT = "DEFAULT"
19
+ DEBUG = "DEBUG"
20
+ INFO = "INFO"
21
+ NOTICE = "NOTICE"
22
+ WARNING = "WARNING"
23
+ ERROR = "ERROR"
24
+ CRITICAL = "CRITICAL"
25
+ ALERT = "ALERT"
26
+ EMERGENCY = "EMERGENCY"
27
+
28
+ obs_app = typer.Typer(help="Read managed execution evidence", cls=DiagnosticGroup, no_args_is_help=True)
29
+ builds_app = typer.Typer(help="Read managed builds and paginated build logs", cls=DiagnosticGroup, no_args_is_help=True)
30
+
31
+
32
+ def observation(resource, options):
33
+ options = dict(options)
34
+ json_mode = options.pop("json_mode")
35
+ project, env, user = (options.pop(k) for k in ("project", "env", "user"))
36
+ run_id = options.get("run_id")
37
+ async def work():
38
+ params, window = query_options(options, scoped=bool(run_id), logs=resource == "logs", default_window=not run_id)
39
+ context = await resolve_context(project, env, user)
40
+ result = await context.api.diagnostic_query(resource, {**context.location, **params}, run_id if resource != "runs" else None)
41
+ return result, window
42
+ execute(work, json_mode)
43
+
44
+
45
+ @obs_app.command("run", cls=DiagnosticCommand)
46
+ def run_summary(
47
+ run_id: str = typer.Argument(...),
48
+ json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
49
+ project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
50
+ env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
51
+ user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
52
+ ):
53
+ """Read one run summary in the selected workspace."""
54
+ async def work():
55
+ context = await resolve_context(project, env, user)
56
+ result = await context.api.diagnostic_run(run_id, context.location)
57
+ context.check_item(result)
58
+ return result, {}
59
+ execute(work, json_mode)
60
+
61
+
62
+ @obs_app.command("runs", cls=DiagnosticCommand)
63
+ def runs(
64
+ json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
65
+ project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
66
+ env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
67
+ user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
68
+ limit: Optional[int] = typer.Option(None, "--limit", help='Page size: 50; run logs: 100 (maximum 500)', min=1, max=500),
69
+ page_token: Optional[str] = typer.Option(None, "--page-token", help='Opaque continuation token; reuse fixed times and filters'),
70
+ order: Optional[Order] = typer.Option(None, "--order", help='Project queries: desc; run/build logs: asc'),
71
+ from_time: Optional[str] = typer.Option(None, "--from", help='Inclusive timezone-aware RFC3339 start'),
72
+ to_time: Optional[str] = typer.Option(None, "--to", help='Timezone-aware RFC3339 end'),
73
+ since: Optional[str] = typer.Option(None, "--since", help='Positive duration: 30m, 2h, 7d'),
74
+ integration: Optional[str] = typer.Option(None, "--integration", help='Exact integration'),
75
+ pipeline: Optional[str] = typer.Option(None, "--pipeline", help='Exact pipeline'),
76
+ run_id: Optional[str] = typer.Option(None, "--run-id", help='Exact run id'),
77
+ span_id: Optional[str] = typer.Option(None, "--span-id", help='Exact span id'),
78
+ trace_id: Optional[str] = typer.Option(None, "--trace-id", help='Exact trace id'),
79
+ parent_run_id: Optional[str] = typer.Option(None, "--parent-run-id", help='Exact parent run id'),
80
+ operation_id: Optional[str] = typer.Option(None, "--operation-id", help='Exact operation id'),
81
+ record_key: Optional[str] = typer.Option(None, "--record-key", help='Exact record key'),
82
+ status: Optional[str] = typer.Option(None, "--status", help='status'),
83
+ ):
84
+ """Read one page of runs; fetch additional pages explicitly."""
85
+ observation("runs", locals())
86
+
87
+
88
+ @obs_app.command("logs", cls=DiagnosticCommand)
89
+ def logs(
90
+ json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
91
+ project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
92
+ env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
93
+ user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
94
+ limit: Optional[int] = typer.Option(None, "--limit", help='Page size: 50; run logs: 100 (maximum 500)', min=1, max=500),
95
+ page_token: Optional[str] = typer.Option(None, "--page-token", help='Opaque continuation token; reuse fixed times and filters'),
96
+ order: Optional[Order] = typer.Option(None, "--order", help='Project queries: desc; run/build logs: asc'),
97
+ from_time: Optional[str] = typer.Option(None, "--from", help='Inclusive timezone-aware RFC3339 start'),
98
+ to_time: Optional[str] = typer.Option(None, "--to", help='Timezone-aware RFC3339 end'),
99
+ since: Optional[str] = typer.Option(None, "--since", help='Positive duration: 30m, 2h, 7d'),
100
+ integration: Optional[str] = typer.Option(None, "--integration", help='Exact integration'),
101
+ pipeline: Optional[str] = typer.Option(None, "--pipeline", help='Exact pipeline'),
102
+ run_id: Optional[str] = typer.Option(None, "--run-id", help='Exact run id'),
103
+ span_id: Optional[str] = typer.Option(None, "--span-id", help='Exact span id'),
104
+ trace_id: Optional[str] = typer.Option(None, "--trace-id", help='Exact trace id'),
105
+ parent_run_id: Optional[str] = typer.Option(None, "--parent-run-id", help='Exact parent run id'),
106
+ operation_id: Optional[str] = typer.Option(None, "--operation-id", help='Exact operation id'),
107
+ record_key: Optional[str] = typer.Option(None, "--record-key", help='Exact record key'),
108
+ severity: Optional[str] = typer.Option(None, "--severity", help='severity'),
109
+ search: Optional[str] = typer.Option(None, "--search", help='search'),
110
+ ):
111
+ """Read one page of logs; fetch additional pages explicitly."""
112
+ observation("logs", locals())
113
+
114
+
115
+ @obs_app.command("spans", cls=DiagnosticCommand)
116
+ def spans(
117
+ json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
118
+ project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
119
+ env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
120
+ user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
121
+ limit: Optional[int] = typer.Option(None, "--limit", help='Page size: 50; run logs: 100 (maximum 500)', min=1, max=500),
122
+ page_token: Optional[str] = typer.Option(None, "--page-token", help='Opaque continuation token; reuse fixed times and filters'),
123
+ order: Optional[Order] = typer.Option(None, "--order", help='Project queries: desc; run/build logs: asc'),
124
+ from_time: Optional[str] = typer.Option(None, "--from", help='Inclusive timezone-aware RFC3339 start'),
125
+ to_time: Optional[str] = typer.Option(None, "--to", help='Timezone-aware RFC3339 end'),
126
+ since: Optional[str] = typer.Option(None, "--since", help='Positive duration: 30m, 2h, 7d'),
127
+ integration: Optional[str] = typer.Option(None, "--integration", help='Exact integration'),
128
+ pipeline: Optional[str] = typer.Option(None, "--pipeline", help='Exact pipeline'),
129
+ run_id: Optional[str] = typer.Option(None, "--run-id", help='Exact run id'),
130
+ span_id: Optional[str] = typer.Option(None, "--span-id", help='Exact span id'),
131
+ trace_id: Optional[str] = typer.Option(None, "--trace-id", help='Exact trace id'),
132
+ parent_run_id: Optional[str] = typer.Option(None, "--parent-run-id", help='Exact parent run id'),
133
+ operation_id: Optional[str] = typer.Option(None, "--operation-id", help='Exact operation id'),
134
+ record_key: Optional[str] = typer.Option(None, "--record-key", help='Exact record key'),
135
+ status: Optional[str] = typer.Option(None, "--status", help='status'),
136
+ search: Optional[str] = typer.Option(None, "--search", help='search'),
137
+ ):
138
+ """Read one page of spans; fetch additional pages explicitly."""
139
+ observation("spans", locals())
140
+
141
+
142
+ @obs_app.command("io", cls=DiagnosticCommand)
143
+ def io(
144
+ json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
145
+ project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
146
+ env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
147
+ user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
148
+ limit: Optional[int] = typer.Option(None, "--limit", help='Page size: 50; run logs: 100 (maximum 500)', min=1, max=500),
149
+ page_token: Optional[str] = typer.Option(None, "--page-token", help='Opaque continuation token; reuse fixed times and filters'),
150
+ order: Optional[Order] = typer.Option(None, "--order", help='Project queries: desc; run/build logs: asc'),
151
+ from_time: Optional[str] = typer.Option(None, "--from", help='Inclusive timezone-aware RFC3339 start'),
152
+ to_time: Optional[str] = typer.Option(None, "--to", help='Timezone-aware RFC3339 end'),
153
+ since: Optional[str] = typer.Option(None, "--since", help='Positive duration: 30m, 2h, 7d'),
154
+ integration: Optional[str] = typer.Option(None, "--integration", help='Exact integration'),
155
+ pipeline: Optional[str] = typer.Option(None, "--pipeline", help='Exact pipeline'),
156
+ run_id: Optional[str] = typer.Option(None, "--run-id", help='Exact run id'),
157
+ span_id: Optional[str] = typer.Option(None, "--span-id", help='Exact span id'),
158
+ trace_id: Optional[str] = typer.Option(None, "--trace-id", help='Exact trace id'),
159
+ parent_run_id: Optional[str] = typer.Option(None, "--parent-run-id", help='Exact parent run id'),
160
+ operation_id: Optional[str] = typer.Option(None, "--operation-id", help='Exact operation id'),
161
+ record_key: Optional[str] = typer.Option(None, "--record-key", help='Exact record key'),
162
+ state: Optional[str] = typer.Option(None, "--state", help='state'),
163
+ remote_system: Optional[str] = typer.Option(None, "--remote-system", help='remote system'),
164
+ channel: Optional[str] = typer.Option(None, "--channel", help='channel'),
165
+ operation: Optional[str] = typer.Option(None, "--operation", help='operation'),
166
+ status_code: Optional[int] = typer.Option(None, "--status-code", help='status code'),
167
+ query: Optional[str] = typer.Option(None, "--query", help='query'),
168
+ ):
169
+ """Read one page of io; fetch additional pages explicitly."""
170
+ observation("data-exchanges", locals())
171
+
172
+
173
+ @obs_app.command("records", cls=DiagnosticCommand)
174
+ def records(
175
+ json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
176
+ project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
177
+ env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
178
+ user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
179
+ limit: Optional[int] = typer.Option(None, "--limit", help='Page size: 50; run logs: 100 (maximum 500)', min=1, max=500),
180
+ page_token: Optional[str] = typer.Option(None, "--page-token", help='Opaque continuation token; reuse fixed times and filters'),
181
+ order: Optional[Order] = typer.Option(None, "--order", help='Project queries: desc; run/build logs: asc'),
182
+ from_time: Optional[str] = typer.Option(None, "--from", help='Inclusive timezone-aware RFC3339 start'),
183
+ to_time: Optional[str] = typer.Option(None, "--to", help='Timezone-aware RFC3339 end'),
184
+ since: Optional[str] = typer.Option(None, "--since", help='Positive duration: 30m, 2h, 7d'),
185
+ integration: Optional[str] = typer.Option(None, "--integration", help='Exact integration'),
186
+ pipeline: Optional[str] = typer.Option(None, "--pipeline", help='Exact pipeline'),
187
+ run_id: Optional[str] = typer.Option(None, "--run-id", help='Exact run id'),
188
+ span_id: Optional[str] = typer.Option(None, "--span-id", help='Exact span id'),
189
+ trace_id: Optional[str] = typer.Option(None, "--trace-id", help='Exact trace id'),
190
+ parent_run_id: Optional[str] = typer.Option(None, "--parent-run-id", help='Exact parent run id'),
191
+ operation_id: Optional[str] = typer.Option(None, "--operation-id", help='Exact operation id'),
192
+ record_key: Optional[str] = typer.Option(None, "--record-key", help='Exact record key'),
193
+ kind: Optional[str] = typer.Option(None, "--kind", help='kind'),
194
+ source: Optional[str] = typer.Option(None, "--source", help='source'),
195
+ ):
196
+ """Read one page of records; fetch additional pages explicitly."""
197
+ observation("records", locals())
198
+
199
+
200
+ @obs_app.command("events", cls=DiagnosticCommand)
201
+ def events(
202
+ json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
203
+ project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
204
+ env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
205
+ user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
206
+ limit: Optional[int] = typer.Option(None, "--limit", help='Page size: 50; run logs: 100 (maximum 500)', min=1, max=500),
207
+ page_token: Optional[str] = typer.Option(None, "--page-token", help='Opaque continuation token; reuse fixed times and filters'),
208
+ order: Optional[Order] = typer.Option(None, "--order", help='Project queries: desc; run/build logs: asc'),
209
+ from_time: Optional[str] = typer.Option(None, "--from", help='Inclusive timezone-aware RFC3339 start'),
210
+ to_time: Optional[str] = typer.Option(None, "--to", help='Timezone-aware RFC3339 end'),
211
+ since: Optional[str] = typer.Option(None, "--since", help='Positive duration: 30m, 2h, 7d'),
212
+ integration: Optional[str] = typer.Option(None, "--integration", help='Exact integration'),
213
+ pipeline: Optional[str] = typer.Option(None, "--pipeline", help='Exact pipeline'),
214
+ run_id: Optional[str] = typer.Option(None, "--run-id", help='Exact run id'),
215
+ span_id: Optional[str] = typer.Option(None, "--span-id", help='Exact span id'),
216
+ trace_id: Optional[str] = typer.Option(None, "--trace-id", help='Exact trace id'),
217
+ parent_run_id: Optional[str] = typer.Option(None, "--parent-run-id", help='Exact parent run id'),
218
+ operation_id: Optional[str] = typer.Option(None, "--operation-id", help='Exact operation id'),
219
+ record_key: Optional[str] = typer.Option(None, "--record-key", help='Exact record key'),
220
+ status: Optional[str] = typer.Option(None, "--status", help='status'),
221
+ event_type: Optional[EventType] = typer.Option(None, "--event-type", help='event type'),
222
+ ):
223
+ """Read one page of events; fetch additional pages explicitly."""
224
+ observation("run-events", locals())
225
+
226
+
227
+ @obs_app.command("payload", cls=DiagnosticCommand)
228
+ def payload(
229
+ payload_ref: str = typer.Argument(...),
230
+ json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
231
+ project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
232
+ env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
233
+ user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
234
+ run_id: Optional[str] = typer.Option(None, "--run-id", help='Captured owning run'),
235
+ ):
236
+ """Fetch one stored payload; binary data remains base64 in JSON."""
237
+ async def work():
238
+ context = await resolve_context(project, env, user)
239
+ params = {**context.location}
240
+ if run_id is not None:
241
+ params["run_id"] = run_id
242
+ return await context.api.diagnostic_payload(payload_ref, params), {}
243
+ execute(work, json_mode)
244
+
245
+
246
+ @builds_app.command("get", cls=DiagnosticCommand)
247
+ def build_get(
248
+ build_id: str = typer.Argument(...),
249
+ json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
250
+ project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
251
+ env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
252
+ user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
253
+ ):
254
+ """Read status using a managed build ID; does not trigger a build."""
255
+ async def work():
256
+ context = await resolve_context(project, env, user)
257
+ result = await context.api.build_status(build_id)
258
+ context.check_item(result, environment=False)
259
+ return result, {}
260
+ execute(work, json_mode)
261
+
262
+
263
+ @builds_app.command("list", cls=DiagnosticCommand)
264
+ def build_list(
265
+ json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
266
+ project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
267
+ env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
268
+ user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
269
+ limit: Optional[int] = typer.Option(None, "--limit", help='Page size: 50; run logs: 100 (maximum 500)', min=1, max=500),
270
+ page_token: Optional[str] = typer.Option(None, "--page-token", help='Opaque continuation token; reuse fixed times and filters'),
271
+ order: Optional[Order] = typer.Option(None, "--order", help='Project queries: desc; run/build logs: asc'),
272
+ from_time: Optional[str] = typer.Option(None, "--from", help='Inclusive timezone-aware RFC3339 start'),
273
+ to_time: Optional[str] = typer.Option(None, "--to", help='Timezone-aware RFC3339 end'),
274
+ since: Optional[str] = typer.Option(None, "--since", help='Positive duration: 30m, 2h, 7d'),
275
+ ):
276
+ """Read one page of environment-neutral builds (server time/order semantics)."""
277
+ options = locals().copy()
278
+ async def work():
279
+ filters = {k: options[k] for k in ("limit", "page_token", "order", "from_time", "to_time", "since")}
280
+ params, window = query_options(filters, scoped=True)
281
+ # The native build-list API has fixed ordering, no environment filter,
282
+ # and optional epoch bounds rather than observability's 24-hour default.
283
+ if order is not None and order != Order.desc:
284
+ from ..core.diagnostics import DiagnosticError
285
+ raise DiagnosticError("INVALID_ORDER", "Build list uses server descending order; use --order desc")
286
+ params.pop("order")
287
+ params["page_size"] = params.pop("limit")
288
+ from ..core.diagnostics import timestamp
289
+ for old, new in (("from_time", "start_ts"), ("to_time", "end_ts")):
290
+ if old in params:
291
+ params[new] = timestamp(params.pop(old)).timestamp()
292
+ context = await resolve_context(project, env, user)
293
+ params["project_id"] = context.info["project_id"]
294
+ return await context.api.get("/v1/builds", params=params), window
295
+ execute(work, json_mode)
296
+
297
+
298
+ @builds_app.command("logs", cls=DiagnosticCommand)
299
+ def build_logs(
300
+ build_id: str = typer.Argument(...),
301
+ json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
302
+ project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
303
+ env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
304
+ user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
305
+ limit: Optional[int] = typer.Option(None, "--limit", help='Page size: 50; run logs: 100 (maximum 500)', min=1, max=500),
306
+ page_token: Optional[str] = typer.Option(None, "--page-token", help='Opaque continuation token; reuse fixed times and filters'),
307
+ order: Optional[Order] = typer.Option(None, "--order", help='Project queries: desc; run/build logs: asc'),
308
+ from_time: Optional[str] = typer.Option(None, "--from", help='Inclusive timezone-aware RFC3339 start'),
309
+ to_time: Optional[str] = typer.Option(None, "--to", help='Timezone-aware RFC3339 end'),
310
+ since: Optional[str] = typer.Option(None, "--since", help='Positive duration: 30m, 2h, 7d'),
311
+ severity: Optional[BuildSeverity] = typer.Option(None, "--severity", help='Cloud Logging severity'),
312
+ search: Optional[str] = typer.Option(None, "--search", help='Cloud Logging quoted text search'),
313
+ ):
314
+ """Read one page of all build log entries, including preamble."""
315
+ options = locals().copy()
316
+ async def work():
317
+ filters = {k: options[k] for k in ("limit", "page_token", "order", "from_time", "to_time", "since", "severity", "search")}
318
+ params, window = query_options(filters, scoped=True)
319
+ context = await resolve_context(project, env, user)
320
+ status = await context.api.build_status(build_id)
321
+ context.check_item(status, environment=False)
322
+ return await context.api.build_log_entries(build_id, params), window
323
+ execute(work, json_mode)
324
+
325
+
326
+ def context_command(
327
+ json_mode: bool = typer.Option(False, "--json", help='Emit the unchanged server JSON'),
328
+ project: Optional[str] = typer.Option(None, "--project", help='Managed project ID'),
329
+ env: Optional[str] = typer.Option(None, "--env", help='Environment (default: prod)'),
330
+ user: Optional[str] = typer.Option(None, "--user", help='Login account; cannot combine with FLOWSTASH_API_KEY'),
331
+ ):
332
+ """Resolve and validate the current managed project and authentication."""
333
+ async def work():
334
+ context = await resolve_context(project, env, user, require_managed=False)
335
+ return context.info, {}
336
+ execute(work, json_mode)
@@ -1,4 +1,6 @@
1
1
  import httpx
2
+ import os
3
+ from urllib.parse import quote
2
4
  from typing import Optional, Dict, Any
3
5
  from .config import load_global_config, get_access_token
4
6
 
@@ -6,7 +8,7 @@ from .config import load_global_config, get_access_token
6
8
  class APIClient:
7
9
  def __init__(self, token: Optional[str] = None):
8
10
  self.config = load_global_config()
9
- self.base_url = self.config.api_url.rstrip("/")
11
+ self.base_url = os.getenv("FLOWSTASH_API_URL", self.config.api_url).rstrip("/")
10
12
  self._token = token # explicit token takes priority over resolved one
11
13
 
12
14
  def _get_headers(self) -> Dict[str, str]:
@@ -24,6 +26,25 @@ class APIClient:
24
26
  response.raise_for_status()
25
27
  return response.json()
26
28
 
29
+ async def diagnostic_query(self, resource: str, params: Dict[str, Any], run_id: Optional[str] = None):
30
+ path = f"/observability/{resource}"
31
+ if run_id is not None:
32
+ suffix = "events" if resource == "run-events" else resource
33
+ path = f"/observability/runs/{quote(run_id, safe='')}/{suffix}"
34
+ return await self.get(path, params=params)
35
+
36
+ async def diagnostic_run(self, run_id: str, params: Dict[str, Any]):
37
+ return await self.get(f"/observability/runs/{quote(run_id, safe='')}", params=params)
38
+
39
+ async def diagnostic_payload(self, ref: str, params: Dict[str, Any]):
40
+ return await self.get("/observability/blobs", params={**params, "ref": ref})
41
+
42
+ async def build_status(self, build_id: str):
43
+ return await self.get(f"/v1/builds/{quote(build_id, safe='')}/status")
44
+
45
+ async def build_log_entries(self, build_id: str, params: Dict[str, Any]):
46
+ return await self.get(f"/v1/builds/{quote(build_id, safe='')}/log-entries", params=params)
47
+
27
48
  async def post(
28
49
  self, path: str, json: Optional[Dict[str, Any]] = None, timeout: float = 30.0
29
50
  ):
@@ -107,7 +107,7 @@ def register_user(
107
107
  save_global_config(config)
108
108
 
109
109
 
110
- def resolve_credentials(user: Optional[str] = None) -> Optional[str]:
110
+ def resolve_credentials(user: Optional[str] = None, project_root: Optional[Path] = None) -> Optional[str]:
111
111
  """
112
112
  Resolve an access token using the priority chain:
113
113
  1. Explicit user (--user flag)
@@ -121,7 +121,8 @@ def resolve_credentials(user: Optional[str] = None) -> Optional[str]:
121
121
  if effective_user:
122
122
  return get_token_for_user(effective_user)
123
123
 
124
- project_config = load_project_config()
124
+ root = project_root if project_root is not None else find_project_root()
125
+ project_config = load_project_config(root) if root is not None else None
125
126
  if project_config and project_config.linked_user:
126
127
  token = get_token_for_user(project_config.linked_user)
127
128
  if token:
@@ -173,9 +174,20 @@ def delete_access_token(email: Optional[str] = None) -> None:
173
174
  pass
174
175
 
175
176
 
176
- def load_project_config() -> Optional[ProjectConfig]:
177
- path = Path(PROJECT_CONFIG_FILE)
178
- if not path.exists():
177
+ def find_project_root(start_path: Optional[Path] = None) -> Optional[Path]:
178
+ """Find the nearest actual .flowstash file, evaluating cwd at invocation."""
179
+ start = (start_path if start_path is not None else Path.cwd()).resolve()
180
+ for parent in (start, *start.parents):
181
+ if (parent / PROJECT_CONFIG_FILE).is_file():
182
+ return parent
183
+ return None
184
+
185
+
186
+ def load_project_config(root: Optional[Path] = None, *, strict: bool = False) -> Optional[ProjectConfig]:
187
+ path = Path(root) if root is not None else Path(PROJECT_CONFIG_FILE)
188
+ if path.is_dir():
189
+ path = path / PROJECT_CONFIG_FILE
190
+ if not path.is_file():
179
191
  return None
180
192
 
181
193
  try:
@@ -183,6 +195,8 @@ def load_project_config() -> Optional[ProjectConfig]:
183
195
  data = yaml.safe_load(f) or {}
184
196
  return ProjectConfig(**data)
185
197
  except Exception:
198
+ if strict:
199
+ raise ValueError(f"Malformed project configuration: {path}") from None
186
200
  return None
187
201
 
188
202
 
@@ -0,0 +1,193 @@
1
+ """Read-only managed context, fixed query windows, and machine-safe output."""
2
+ import asyncio
3
+ import json
4
+ import os
5
+ import re
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timedelta, timezone
8
+ from typing import Optional
9
+ from urllib.parse import quote
10
+
11
+ import click
12
+ import httpx
13
+ import typer
14
+ from typer.core import TyperCommand, TyperGroup
15
+
16
+ from .api_client import APIClient
17
+ from .config import find_project_root, load_project_config, resolve_credentials
18
+
19
+
20
+ class DiagnosticError(Exception):
21
+ def __init__(self, code, message, exit_code=2, http_status=None):
22
+ self.code, self.message = code, message
23
+ self.exit_code, self.http_status = exit_code, http_status
24
+
25
+
26
+ def fail(error, json_mode):
27
+ detail = {"code": error.code, "message": error.message}
28
+ if error.http_status is not None:
29
+ detail["http_status"] = error.http_status
30
+ typer.echo(json.dumps({"error": detail}) if json_mode else error.message, err=True)
31
+ raise typer.Exit(error.exit_code)
32
+
33
+
34
+ class MachineParsing:
35
+ """Keep Click's own parameter/unknown-option errors machine readable too."""
36
+ def parse_args(self, ctx, args):
37
+ json_mode = "--json" in args
38
+ try:
39
+ return super().parse_args(ctx, args)
40
+ except click.ClickException as exc:
41
+ fail(DiagnosticError("INVALID_PARAMETERS", exc.format_message()), json_mode)
42
+
43
+
44
+ class DiagnosticCommand(MachineParsing, TyperCommand):
45
+ pass
46
+
47
+
48
+ class DiagnosticGroup(MachineParsing, TyperGroup):
49
+ def resolve_command(self, ctx, args):
50
+ try:
51
+ return super().resolve_command(ctx, args)
52
+ except click.ClickException as exc:
53
+ fail(DiagnosticError("INVALID_PARAMETERS", exc.format_message()), "--json" in args)
54
+
55
+
56
+ @dataclass
57
+ class ManagedContext:
58
+ api: APIClient
59
+ info: dict
60
+
61
+ @property
62
+ def location(self):
63
+ return {"project_id": self.info["project_id"], "environment": self.info["environment"]}
64
+
65
+ def check_item(self, item, *, environment=True):
66
+ # Single-item backend routes may not accept workspace query filters.
67
+ if item.get("project_id") != self.info["project_id"] or (
68
+ environment and item.get("environment") != self.info["environment"]
69
+ ):
70
+ raise DiagnosticError("NOT_FOUND", "Item is outside the selected project/environment", 4, 404)
71
+
72
+
73
+ async def resolve_context(project=None, env=None, user=None, *, require_managed=True):
74
+ root = find_project_root()
75
+ try:
76
+ local = load_project_config(root, strict=True) if root else None
77
+ except ValueError as exc:
78
+ raise DiagnosticError("INVALID_CONTEXT", str(exc)) from None
79
+ project_id = project or (local.project_id if local else None)
80
+ if not project_id:
81
+ raise DiagnosticError("PROJECT_REQUIRED", "Link a .flowstash project or supply --project PROJECT_ID")
82
+ environment = env or "prod"
83
+ managed = True
84
+ if local and local.environments:
85
+ mode = next((e for e in local.environments if e.name == environment), None)
86
+ if mode is None:
87
+ raise DiagnosticError("UNKNOWN_ENVIRONMENT", f"Unknown environment '{environment}'; configured: {', '.join(e.name for e in local.environments)}")
88
+ managed = mode.managed
89
+ if require_managed and not managed:
90
+ raise DiagnosticError("LOCAL_ENVIRONMENT", f"Environment '{environment}' is local; diagnostics require a managed --env")
91
+ key = os.getenv("FLOWSTASH_API_KEY")
92
+ if key and user:
93
+ raise DiagnosticError("AMBIGUOUS_IDENTITY", "Do not combine --user with FLOWSTASH_API_KEY")
94
+ token = key or resolve_credentials(user=user, project_root=root)
95
+ if not token:
96
+ message = f"No token for requested user '{user}'" if user else "Run flowstash login or configure FLOWSTASH_API_KEY"
97
+ raise DiagnosticError("AUTH_REQUIRED", message, 3, 401)
98
+ api = APIClient(token=token)
99
+ identity = await api.get("/v1/auth/me")
100
+ requested_user = user or (os.getenv("FLOWSTASH_USER") if not key else None)
101
+ if requested_user and identity.get("email") != requested_user:
102
+ raise DiagnosticError("IDENTITY_MISMATCH", "Authenticated identity differs from the requested user; log in again for that user", 3, 401)
103
+ tenant = identity.get("tenant_id")
104
+ if local and local.tenant_id and local.tenant_id != tenant:
105
+ raise DiagnosticError("TENANT_MISMATCH", "Local tenant differs from authenticated tenant; supply the correct --user or FLOWSTASH_API_KEY")
106
+ if identity.get("project_id") and identity["project_id"] != project_id:
107
+ raise DiagnosticError("PROJECT_RESTRICTION", "API key is restricted to another project; select its --project", 3, 403)
108
+ if identity.get("environment") and identity["environment"] != environment:
109
+ raise DiagnosticError("ENVIRONMENT_RESTRICTION", f"API key requires --env {identity['environment']}", 3, 403)
110
+ remote = await api.get(f"/v1/projects/{quote(project_id, safe='')}")
111
+ if remote.get("tenant_id") != tenant or remote.get("project_id") != project_id:
112
+ raise DiagnosticError("TENANT_MISMATCH", "Remote project does not match authenticated tenant/project", 3, 403)
113
+ return ManagedContext(api, {
114
+ "project_root": str(root) if root else None,
115
+ "project_id": project_id, "project_name": remote.get("name"),
116
+ "tenant_id": tenant, "environment": environment, "managed": managed,
117
+ "api_url": api.base_url, "auth_method": "api_key" if key else "login",
118
+ "user_email": identity.get("email"),
119
+ "project_source": "explicit" if project else "local_config",
120
+ })
121
+
122
+
123
+ def timestamp(value):
124
+ if value is None:
125
+ return None
126
+ if not re.fullmatch(r"\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})", value):
127
+ raise DiagnosticError("INVALID_TIME", "Timestamps must be timezone-aware RFC3339 values")
128
+ try:
129
+ return datetime.fromisoformat(value.upper().replace("Z", "+00:00")).astimezone(timezone.utc)
130
+ except ValueError:
131
+ raise DiagnosticError("INVALID_TIME", "Invalid RFC3339 timestamp") from None
132
+
133
+
134
+ def time_window(from_time=None, to_time=None, since=None, *, default_window=False, page_token=None):
135
+ start, end = timestamp(from_time), timestamp(to_time)
136
+ duration = None
137
+ if since is not None:
138
+ if from_time is not None:
139
+ raise DiagnosticError("INVALID_TIME", "--since cannot be combined with --from")
140
+ match = re.fullmatch(r"([1-9][0-9]*)([smhd])", since)
141
+ if not match:
142
+ raise DiagnosticError("INVALID_TIME", "--since requires a positive integer duration, e.g. 30m, 2h, 7d")
143
+ try:
144
+ duration = timedelta(seconds=int(match[1]) * {"s": 1, "m": 60, "h": 3600, "d": 86400}[match[2]])
145
+ except (OverflowError, ValueError):
146
+ raise DiagnosticError("INVALID_TIME", "--since duration is too large") from None
147
+ # Never manufacture different timestamps for a continuation. An opaque cursor
148
+ # cannot tell us the exact timestamps sent by an earlier CLI process.
149
+ if page_token and (since or (default_window and (start is None or end is None))):
150
+ raise DiagnosticError("FIXED_WINDOW_REQUIRED", "For pagination reuse the exact --from and --to from the first page; do not use --since")
151
+ try:
152
+ if default_window or duration:
153
+ end = end or datetime.now(timezone.utc)
154
+ start = start or end - (duration or timedelta(hours=24))
155
+ except OverflowError:
156
+ raise DiagnosticError("INVALID_TIME", "Time range is outside supported dates") from None
157
+ if start and end and start >= end:
158
+ raise DiagnosticError("INVALID_TIME", "--from must be before --to")
159
+ return {key: value.isoformat().replace("+00:00", "Z") for key, value in (("from_time", start), ("to_time", end)) if value is not None}
160
+
161
+
162
+ def query_options(options, *, scoped=False, logs=False, default_window=False):
163
+ options = dict(options)
164
+ window = time_window(options.pop("from_time", None), options.pop("to_time", None), options.pop("since", None), default_window=default_window, page_token=options.get("page_token"))
165
+ limit = options.get("limit")
166
+ options["limit"] = limit if limit is not None else (100 if logs and scoped else 50)
167
+ options["order"] = options.get("order") or ("asc" if scoped else "desc")
168
+ return {k: v for k, v in {**options, **window}.items() if v is not None}, window
169
+
170
+
171
+ def execute(work, json_mode=False):
172
+ try:
173
+ result, window = asyncio.run(work())
174
+ except DiagnosticError as exc:
175
+ fail(exc, json_mode)
176
+ except httpx.HTTPStatusError as exc:
177
+ status = exc.response.status_code
178
+ codes = {400: ("INVALID_QUERY", 2), 401: ("AUTH_REQUIRED", 3), 403: ("FORBIDDEN", 3), 404: ("NOT_FOUND", 4), 422: ("INVALID_PARAMETERS", 2)}
179
+ code, exit_code = codes.get(status, ("PROVIDER_FAILURE", 5))
180
+ # Do not print request URLs/headers or arbitrary provider response bodies:
181
+ # they can contain credentials, cursor values, and untrusted content.
182
+ messages = {400: "Invalid filter or cursor; start a fresh search if filters changed", 401: "Run flowstash login or configure FLOWSTASH_API_KEY", 403: "Access denied; check observability:read scope and project/environment restrictions", 404: "Item missing or inaccessible; recent evidence may still be ingesting", 422: "Invalid request parameters", 502: "Provider or storage unavailable"}
183
+ fail(DiagnosticError(code, messages.get(status, "Managed API request failed"), exit_code, status), json_mode)
184
+ except httpx.RequestError:
185
+ fail(DiagnosticError("NETWORK_FAILURE", "Unable to reach the managed API", 5), json_mode)
186
+ except Exception:
187
+ fail(DiagnosticError("CLIENT_FAILURE", "Unable to read diagnostics; check configuration and credential storage", 5), json_mode)
188
+ if not json_mode:
189
+ if window:
190
+ typer.echo("Time range: " + " ".join(f"--{k.replace('_time', '')} {v}" for k, v in window.items()))
191
+ if isinstance(result, dict) and result.get("next_page_token"):
192
+ typer.echo(f"Next page: --page-token {result['next_page_token']} (reuse the exact time range and filters)")
193
+ typer.echo(json.dumps(result, ensure_ascii=False, indent=None if json_mode else 2))
@@ -9,6 +9,8 @@ from .commands import run as run_cmds
9
9
  from .commands import webhook as webhook_cmds
10
10
  from .commands import apikey as apikey_cmds
11
11
  from .commands import client as client_cmds
12
+ from .commands import diagnostics as diagnostic_cmds
13
+ from .core.diagnostics import DiagnosticCommand
12
14
 
13
15
  from rich.console import Console
14
16
  import importlib.metadata
@@ -34,6 +36,10 @@ Run commands below to initialize, build, and deploy your integrations.
34
36
  rich_markup_mode="rich",
35
37
  )
36
38
 
39
+ app.add_typer(diagnostic_cmds.obs_app, name="obs")
40
+ app.add_typer(diagnostic_cmds.builds_app, name="builds")
41
+ app.command("context", cls=DiagnosticCommand)(diagnostic_cmds.context_command)
42
+
37
43
  env_app = typer.Typer(
38
44
  name="env",
39
45
  help="Manage environments",