buildai-cli 0.3.61__tar.gz → 0.3.63__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 (35) hide show
  1. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/PKG-INFO +1 -1
  2. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/auth_local.py +21 -6
  3. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/gigcamera.py +224 -0
  4. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/context.py +7 -4
  5. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/db_broker.py +140 -6
  6. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/pyproject.toml +1 -1
  7. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/.gitignore +0 -0
  8. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/AGENTS.md +0 -0
  9. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/CLAUDE.md +0 -0
  10. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/buildai_bootstrap.py +0 -0
  11. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/__init__.py +0 -0
  12. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/_has_core.py +0 -0
  13. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/__init__.py +0 -0
  14. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/api_proxy.py +0 -0
  15. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/auth.py +0 -0
  16. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/db/__init__.py +0 -0
  17. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/db/broker.py +0 -0
  18. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/db/common.py +0 -0
  19. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/db/migrate.py +0 -0
  20. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/db/query.py +0 -0
  21. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/db/schema.py +0 -0
  22. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/db/status.py +0 -0
  23. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/dev.py +0 -0
  24. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/doctor.py +0 -0
  25. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/commands/processing.py +0 -0
  26. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/config.py +0 -0
  27. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/console.py +0 -0
  28. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/guard.py +0 -0
  29. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/internal_api.py +0 -0
  30. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/main.py +0 -0
  31. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/nl_query/__init__.py +0 -0
  32. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/nl_query/dataset_tools.py +0 -0
  33. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/ops_init.py +0 -0
  34. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/output.py +0 -0
  35. {buildai_cli-0.3.61 → buildai_cli-0.3.63}/cli/pagination.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: buildai-cli
3
- Version: 0.3.61
3
+ Version: 0.3.63
4
4
  Summary: Build AI CLI (Typer)
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.27.0
@@ -353,12 +353,27 @@ def _fixed_targets_for_environment(
353
353
  def _run_command(argv: list[str], *, timeout: int = 20) -> CommandResult:
354
354
  """Run one external command with bounded time and normalized output fields."""
355
355
 
356
- completed = subprocess.run(
357
- argv,
358
- capture_output=True,
359
- text=True,
360
- timeout=timeout,
361
- )
356
+ try:
357
+ completed = subprocess.run(
358
+ argv,
359
+ capture_output=True,
360
+ text=True,
361
+ timeout=timeout,
362
+ )
363
+ except FileNotFoundError as exc:
364
+ return CommandResult(
365
+ argv=tuple(argv),
366
+ returncode=127,
367
+ stdout="",
368
+ stderr=f"{argv[0]} is not installed or is not on PATH: {exc}",
369
+ )
370
+ except subprocess.TimeoutExpired as exc:
371
+ return CommandResult(
372
+ argv=tuple(argv),
373
+ returncode=124,
374
+ stdout=(exc.stdout or "").strip() if isinstance(exc.stdout, str) else "",
375
+ stderr=(exc.stderr or "").strip() if isinstance(exc.stderr, str) else "Command timed out.",
376
+ )
362
377
  return CommandResult(
363
378
  argv=tuple(argv),
364
379
  returncode=completed.returncode,
@@ -5,12 +5,15 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import hashlib
7
7
  from datetime import date
8
+ from decimal import Decimal
8
9
  from typing import Literal
9
10
  from uuid import UUID
10
11
 
11
12
  import typer
12
13
 
14
+ from cli.console import error
13
15
  from cli.context import get_cli_context, get_inspection_connection
16
+ from cli.db_broker import BrokerConnectionError
14
17
  from cli.ops_init import init_ops_context
15
18
  from cli.output import Format, format_option, render
16
19
 
@@ -106,6 +109,150 @@ def _idempotency_key(base: str, *, run_suffix: str | None) -> str:
106
109
  return f"{base}:{suffix}" if suffix else base
107
110
 
108
111
 
112
+ async def _stale_consolidated_source_rows(
113
+ db: object,
114
+ *,
115
+ day_ist: date,
116
+ source_ids: list[UUID] | None,
117
+ ) -> list[dict[str, object]]:
118
+ """List approved source rows not actually covered by same-day consolidation."""
119
+
120
+ rows = await db.fetch(
121
+ """
122
+ WITH source_rows AS (
123
+ SELECT source.*
124
+ FROM finance.payouts source
125
+ WHERE COALESCE(source.metadata->>'gigcamera_surface', 'false') = 'true'
126
+ AND source.metadata->>'payout_day_ist' = $1::text
127
+ AND COALESCE(source.metadata->>'payout_kind', 'participant_earnings') IN (
128
+ 'participant_earnings',
129
+ 'participant_referral_bonus'
130
+ )
131
+ AND source.status = 'approved'
132
+ AND ($2::uuid[] IS NULL OR source.id = ANY($2::uuid[]))
133
+ AND NOT EXISTS (
134
+ SELECT 1
135
+ FROM finance.disbursements source_disbursement
136
+ WHERE source_disbursement.payout_id = source.id
137
+ AND source_disbursement.status IN ('settled', 'pending', 'sent', 'processing')
138
+ )
139
+ ),
140
+ stale_sources AS (
141
+ SELECT DISTINCT ON (source.id)
142
+ source.id AS source_id,
143
+ source.metadata->>'person_display_name' AS person_display_name,
144
+ source.metadata->>'person_id' AS person_id,
145
+ COALESCE(
146
+ source.metadata->>'payout_kind',
147
+ 'participant_earnings'
148
+ ) AS source_kind,
149
+ source.net_amount AS source_amount,
150
+ source.metadata->>'gigcamera_provider_state' AS source_provider_state,
151
+ source.metadata->>'gigcamera_reason' AS source_provider_reason,
152
+ consolidated.id AS consolidated_id,
153
+ consolidated.status AS consolidated_status,
154
+ consolidated.net_amount AS consolidated_amount,
155
+ consolidated.metadata->>'worker_amount_inr' AS consolidated_worker_amount,
156
+ consolidated.metadata->>'referral_amount_inr' AS consolidated_referral_amount,
157
+ consolidated.metadata->'source_payout_ids' AS consolidated_source_ids,
158
+ latest.status AS consolidated_disbursement_status,
159
+ latest.reconciled_at AS consolidated_reconciled_at
160
+ FROM source_rows source
161
+ JOIN finance.payouts consolidated
162
+ ON consolidated.org_id = source.org_id
163
+ AND COALESCE(consolidated.metadata->>'gigcamera_surface', 'false') = 'true'
164
+ AND consolidated.metadata->>'payout_kind' = 'participant_consolidated'
165
+ AND consolidated.metadata->>'person_id' = source.metadata->>'person_id'
166
+ AND consolidated.metadata->>'payout_day_ist' = source.metadata->>'payout_day_ist'
167
+ AND consolidated.status NOT IN ('voided', 'skipped')
168
+ LEFT JOIN LATERAL (
169
+ SELECT status, reconciled_at
170
+ FROM finance.disbursements disbursement
171
+ WHERE disbursement.payout_id = consolidated.id
172
+ ORDER BY attempt_index DESC, created_at DESC
173
+ LIMIT 1
174
+ ) latest ON true
175
+ WHERE NOT (
176
+ COALESCE(consolidated.metadata->'source_payout_ids', '[]'::jsonb)
177
+ ? source.id::text
178
+ )
179
+ AND NOT EXISTS (
180
+ SELECT 1
181
+ FROM finance.payouts explicit_consolidated
182
+ WHERE explicit_consolidated.org_id = source.org_id
183
+ AND COALESCE(
184
+ explicit_consolidated.metadata->>'gigcamera_surface',
185
+ 'false'
186
+ ) = 'true'
187
+ AND explicit_consolidated.metadata->>'payout_kind'
188
+ = 'participant_consolidated'
189
+ AND explicit_consolidated.metadata->>'person_id'
190
+ = source.metadata->>'person_id'
191
+ AND explicit_consolidated.metadata->>'payout_day_ist'
192
+ = source.metadata->>'payout_day_ist'
193
+ AND explicit_consolidated.status NOT IN ('voided', 'skipped')
194
+ AND COALESCE(
195
+ explicit_consolidated.metadata->'source_payout_ids',
196
+ '[]'::jsonb
197
+ ) ? source.id::text
198
+ )
199
+ ORDER BY source.id, consolidated.created_at DESC, consolidated.id DESC
200
+ )
201
+ SELECT *
202
+ FROM stale_sources
203
+ ORDER BY person_display_name, source_id
204
+ """,
205
+ day_ist.isoformat(),
206
+ source_ids,
207
+ )
208
+ return [dict(row) for row in rows]
209
+
210
+
211
+ async def _clear_false_consolidated_submit_blocks(
212
+ db: object,
213
+ *,
214
+ source_ids: list[UUID],
215
+ ) -> list[UUID]:
216
+ """Clear only metadata written by the old false consolidation submit guard."""
217
+
218
+ if not source_ids:
219
+ return []
220
+ rows = await db.fetch(
221
+ """
222
+ UPDATE finance.payouts payout
223
+ SET metadata = jsonb_strip_nulls(
224
+ (
225
+ COALESCE(payout.metadata, '{}'::jsonb)
226
+ - 'gigcamera_provider_state'
227
+ - 'gigcamera_provider_review_needed'
228
+ - 'gigcamera_reason'
229
+ - 'gigcamera_provider_status'
230
+ - 'gigcamera_provider_status_code'
231
+ - 'gigcamera_retry_policy'
232
+ - 'gigcamera_retry_available'
233
+ - 'gigcamera_retry_guidance'
234
+ )
235
+ || jsonb_build_object(
236
+ 'gigcamera_consolidation_repair',
237
+ jsonb_build_object(
238
+ 'kind', 'cleared_false_consolidated_submit_block',
239
+ 'cleared_at', now()::text
240
+ )
241
+ )
242
+ ),
243
+ updated_at = now()
244
+ WHERE payout.id = ANY($1::uuid[])
245
+ AND payout.status = 'approved'
246
+ AND payout.metadata->>'gigcamera_provider_state' = 'blocked_destination'
247
+ AND COALESCE(payout.metadata->>'gigcamera_reason', '')
248
+ LIKE 'this row has been consolidated into payout %; submit the consolidated row instead'
249
+ RETURNING payout.id
250
+ """,
251
+ source_ids,
252
+ )
253
+ return [UUID(str(row["id"])) for row in rows]
254
+
255
+
109
256
  async def _resolve_program_version_id(
110
257
  conn: object,
111
258
  *,
@@ -213,6 +360,83 @@ async def _delete_stale_recaps(conn: object, *, program_version_id: UUID) -> Non
213
360
  )
214
361
 
215
362
 
363
+ @app.command("stale-consolidation-sources")
364
+ def stale_consolidation_sources(
365
+ ctx: typer.Context,
366
+ day: str = typer.Option(..., "--day", help="IST payout day to inspect (YYYY-MM-DD)."),
367
+ source_id: list[str] | None = typer.Option(
368
+ None,
369
+ "--source-id",
370
+ help="Optional source payout id to constrain the repair; repeatable.",
371
+ ),
372
+ write: bool = typer.Option(
373
+ False,
374
+ "--write",
375
+ help=(
376
+ "Clear bogus blocked-destination metadata on affected source rows. "
377
+ "Does not submit payments."
378
+ ),
379
+ ),
380
+ format: Format = format_option(),
381
+ ) -> None:
382
+ """Audit stale source rows left outside settled participant-day consolidation."""
383
+
384
+ parsed_day = _parse_ist_day(day, field_name="--day")
385
+ parsed_source_ids = [UUID(value) for value in source_id or []] or None
386
+ settings = _settings_for_command(ctx, write=write)
387
+ _require_internal_admin_for_write(ctx, write=write)
388
+
389
+ async def run() -> None:
390
+ async with get_inspection_connection(settings) as conn:
391
+ rows = await _stale_consolidated_source_rows(
392
+ conn,
393
+ day_ist=parsed_day,
394
+ source_ids=parsed_source_ids,
395
+ )
396
+ cleared_ids: list[UUID] = []
397
+ if write:
398
+ async with conn.transaction():
399
+ cleared_ids = await _clear_false_consolidated_submit_blocks(
400
+ conn,
401
+ source_ids=[UUID(str(row["source_id"])) for row in rows],
402
+ )
403
+
404
+ delta_ids = [str(row["source_id"]) for row in rows]
405
+ block_repair_candidate_ids = [
406
+ str(row["source_id"])
407
+ for row in rows
408
+ if row.get("source_provider_state") == "blocked_destination"
409
+ and str(row.get("source_provider_reason") or "").startswith(
410
+ "this row has been consolidated into payout "
411
+ )
412
+ ]
413
+ delta_total = sum(
414
+ (row["source_amount"] for row in rows),
415
+ Decimal("0"),
416
+ ).quantize(Decimal("0.01"))
417
+ render(
418
+ {
419
+ "dry_run": not write,
420
+ "day": parsed_day.isoformat(),
421
+ "delta_payout_count": len(delta_ids),
422
+ "delta_payout_total_inr": delta_total,
423
+ "delta_payout_ids": delta_ids,
424
+ "blocked_destination_repair_candidate_count": len(block_repair_candidate_ids),
425
+ "blocked_destination_repair_candidate_ids": block_repair_candidate_ids,
426
+ "cleared_blocked_destination_count": len(cleared_ids),
427
+ "cleared_blocked_destination_ids": [str(value) for value in cleared_ids],
428
+ "rows": rows,
429
+ },
430
+ format=format,
431
+ )
432
+
433
+ try:
434
+ asyncio.run(run())
435
+ except BrokerConnectionError as exc:
436
+ error(str(exc))
437
+ raise typer.Exit(1) from None
438
+
439
+
216
440
  @app.command("vlm-purge-stale-recaps")
217
441
  def vlm_purge_stale_recaps(
218
442
  ctx: typer.Context,
@@ -220,10 +220,13 @@ def resolve_broker_config(
220
220
 
221
221
  resolved_profile = resolve_cli_profile(profile)
222
222
  db_identity_profile = db_identity_profile_for_cli_profile(resolved_profile)
223
- config = _fixed_profile_connection_config(settings, profile=db_identity_profile)
224
-
225
- if config is None and resolved_profile in _ADMIN_DB_IDENTITY_PROFILES:
226
- config = resolve_admin_connection_config(settings)
223
+ if resolved_profile in _ADMIN_DB_IDENTITY_PROFILES:
224
+ config = resolve_admin_connection_config(settings) or _fixed_profile_connection_config(
225
+ settings,
226
+ profile=db_identity_profile,
227
+ )
228
+ else:
229
+ config = _fixed_profile_connection_config(settings, profile=db_identity_profile)
227
230
 
228
231
  if config is None:
229
232
  if resolved_profile in _ADMIN_DB_IDENTITY_PROFILES:
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import fcntl
5
+ import ctypes
6
6
  import hashlib
7
7
  import json
8
8
  import os
@@ -20,6 +20,11 @@ from typing import Any, Iterator
20
20
  import asyncpg
21
21
  from infra.settings import Settings
22
22
 
23
+ if os.name == "nt":
24
+ import msvcrt
25
+ else:
26
+ import fcntl
27
+
23
28
  REPO_ROOT = Path(__file__).resolve().parents[3]
24
29
  LEGACY_STATE_DIR = REPO_ROOT / ".buildai" / "db-brokers"
25
30
  STATE_DIR = Path(os.getenv("BUILDAI_DB_BROKER_STATE_DIR", Path.home() / ".buildai" / "db-brokers"))
@@ -192,6 +197,9 @@ def proxy_is_listening(*, host: str, port: int) -> bool:
192
197
  def process_is_running(pid: int) -> bool:
193
198
  """Return whether the broker PID still exists."""
194
199
 
200
+ if os.name == "nt":
201
+ return _windows_process_is_running(pid)
202
+
195
203
  try:
196
204
  os.kill(pid, 0)
197
205
  except ProcessLookupError:
@@ -201,18 +209,59 @@ def process_is_running(pid: int) -> bool:
201
209
  return True
202
210
 
203
211
 
212
+ def _windows_process_is_running(pid: int) -> bool:
213
+ """Check a Windows process handle without sending a signal to the process."""
214
+
215
+ if pid <= 0:
216
+ return False
217
+
218
+ synchronize = 0x00100000
219
+ wait_timeout = 0x00000102
220
+ kernel32 = ctypes.windll.kernel32
221
+ handle = kernel32.OpenProcess(synchronize, False, int(pid))
222
+ if not handle:
223
+ return False
224
+ try:
225
+ return kernel32.WaitForSingleObject(handle, 0) == wait_timeout
226
+ finally:
227
+ kernel32.CloseHandle(handle)
228
+
229
+
230
+ def _lock_file(handle: Any) -> None:
231
+ """Acquire an exclusive machine-local lock for the opened lock file."""
232
+
233
+ if os.name == "nt":
234
+ handle.seek(0)
235
+ handle.write(b"\0")
236
+ handle.flush()
237
+ handle.seek(0)
238
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
239
+ return
240
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
241
+
242
+
243
+ def _unlock_file(handle: Any) -> None:
244
+ """Release a lock previously acquired by ``_lock_file``."""
245
+
246
+ if os.name == "nt":
247
+ handle.seek(0)
248
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
249
+ return
250
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
251
+
252
+
204
253
  @contextmanager
205
254
  def _broker_lock(config: BrokerConfig) -> Iterator[None]:
206
255
  """Serialize local broker startup for one profile identity on this machine."""
207
256
 
208
257
  STATE_DIR.mkdir(parents=True, exist_ok=True)
209
258
  lock_path = STATE_DIR / f"{config.identity}.lock"
210
- with lock_path.open("w", encoding="utf-8") as handle:
211
- fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
259
+ with lock_path.open("a+b") as handle:
260
+ _lock_file(handle)
212
261
  try:
213
262
  yield
214
263
  finally:
215
- fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
264
+ _unlock_file(handle)
216
265
 
217
266
 
218
267
  def _state_paths(config: BrokerConfig) -> tuple[Path, ...]:
@@ -286,6 +335,9 @@ def _write_broker_state(config: BrokerConfig, state: BrokerState) -> None:
286
335
  def _process_command(pid: int) -> list[str]:
287
336
  """Return argv-like command text for a local PID when the OS exposes it."""
288
337
 
338
+ if os.name == "nt":
339
+ return _windows_process_command(pid)
340
+
289
341
  try:
290
342
  completed = subprocess.run(
291
343
  ["ps", "-p", str(pid), "-o", "command="],
@@ -307,9 +359,44 @@ def _process_command(pid: int) -> list[str]:
307
359
  return raw.split()
308
360
 
309
361
 
362
+ def _windows_process_command(pid: int) -> list[str]:
363
+ """Return command-line tokens for a Windows process when PowerShell exposes it."""
364
+
365
+ try:
366
+ completed = subprocess.run(
367
+ [
368
+ "powershell",
369
+ "-NoProfile",
370
+ "-Command",
371
+ (
372
+ "$p = Get-CimInstance Win32_Process -Filter "
373
+ f"'ProcessId = {int(pid)}'; if ($p) {{ $p.CommandLine }}"
374
+ ),
375
+ ],
376
+ capture_output=True,
377
+ text=True,
378
+ timeout=2,
379
+ check=False,
380
+ )
381
+ except Exception:
382
+ return []
383
+ if completed.returncode != 0:
384
+ return []
385
+ raw = completed.stdout.strip()
386
+ if not raw:
387
+ return []
388
+ try:
389
+ return shlex.split(raw, posix=False)
390
+ except ValueError:
391
+ return raw.split()
392
+
393
+
310
394
  def _listening_pids(*, host: str, port: int) -> list[int]:
311
395
  """Return local PIDs listening on a TCP port when OS tools expose them."""
312
396
 
397
+ if os.name == "nt":
398
+ return _windows_listening_pids(host=host, port=port)
399
+
313
400
  if shutil.which("lsof") is not None:
314
401
  try:
315
402
  completed = subprocess.run(
@@ -355,6 +442,41 @@ def _listening_pids(*, host: str, port: int) -> list[int]:
355
442
  return []
356
443
 
357
444
 
445
+ def _windows_listening_pids(*, host: str, port: int) -> list[int]:
446
+ """Return Windows PIDs listening on a local TCP port via netstat."""
447
+
448
+ try:
449
+ completed = subprocess.run(
450
+ ["netstat", "-ano", "-p", "tcp"],
451
+ capture_output=True,
452
+ text=True,
453
+ timeout=2,
454
+ check=False,
455
+ )
456
+ except Exception:
457
+ return []
458
+ if completed.returncode != 0:
459
+ return []
460
+
461
+ pids: list[int] = []
462
+ port_suffix = f":{port}"
463
+ for line in completed.stdout.splitlines():
464
+ parts = line.split()
465
+ if len(parts) < 5 or parts[0].upper() != "TCP":
466
+ continue
467
+ local_address = parts[1]
468
+ state = parts[3].upper()
469
+ if state != "LISTENING" or not local_address.endswith(port_suffix):
470
+ continue
471
+ if host not in ("0.0.0.0", "::", "localhost") and not local_address.startswith(host):
472
+ continue
473
+ try:
474
+ pids.append(int(parts[-1]))
475
+ except ValueError:
476
+ continue
477
+ return pids
478
+
479
+
358
480
  def _listener_processes(*, host: str, port: int) -> list[ListenerProcess]:
359
481
  """Return process metadata for listeners on the broker endpoint."""
360
482
 
@@ -382,7 +504,8 @@ def _is_matching_proxy_process(process: ListenerProcess, config: BrokerConfig) -
382
504
  command = process.command
383
505
  if not command:
384
506
  return False
385
- if Path(command[0]).name != "alloydb-auth-proxy":
507
+ executable_name = Path(command[0].strip('"')).name.lower()
508
+ if executable_name not in {"alloydb-auth-proxy", "alloydb-auth-proxy.exe"}:
386
509
  return False
387
510
  if config.instance_uri not in command:
388
511
  return False
@@ -435,6 +558,17 @@ def _broker_command(config: BrokerConfig, *, proxy_binary: str) -> list[str]:
435
558
  return command
436
559
 
437
560
 
561
+ def _proxy_popen_kwargs() -> dict[str, Any]:
562
+ """Return process-detach kwargs appropriate for the current platform."""
563
+
564
+ if os.name != "nt":
565
+ return {"start_new_session": True}
566
+ creationflags = 0
567
+ creationflags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
568
+ creationflags |= getattr(subprocess, "DETACHED_PROCESS", 0)
569
+ return {"creationflags": creationflags}
570
+
571
+
438
572
  def _recent_broker_log(config: BrokerConfig, *, max_bytes: int = 12000) -> str:
439
573
  """Return the recent proxy log text that explains connection resets."""
440
574
 
@@ -565,8 +699,8 @@ def ensure_broker(config: BrokerConfig) -> BrokerState:
565
699
  command,
566
700
  stdout=log_file,
567
701
  stderr=log_file,
568
- start_new_session=True,
569
702
  env=os.environ.copy(),
703
+ **_proxy_popen_kwargs(),
570
704
  )
571
705
 
572
706
  deadline = time.monotonic() + 15.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "buildai-cli"
7
- version = "0.3.61"
7
+ version = "0.3.63"
8
8
  description = "Build AI CLI (Typer)"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes