contree-cli 0.7.0__tar.gz → 0.8.0__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.
- {contree_cli-0.7.0 → contree_cli-0.8.0}/PKG-INFO +1 -1
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/file.py +22 -24
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/images.py +13 -15
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/operation.py +30 -19
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/run.py +264 -15
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/show.py +1 -1
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/client.py +226 -38
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_run.py +30 -18
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/url_fetch.py +13 -48
- {contree_cli-0.7.0 → contree_cli-0.8.0}/pyproject.toml +2 -1
- {contree_cli-0.7.0 → contree_cli-0.8.0}/LICENSE +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/README.md +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/__init__.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/__main__.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/agent.md +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/arguments.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/__init__.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/agent.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/auth.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/build.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/cat.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/cd.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/cp.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/env.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/ls.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/session.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/skill.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/tag.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/cli/use.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/config.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/__init__.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/context.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/dockerignore.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/keyword.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_add.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_arg.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_copy.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_env.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_from.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_skipped.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_user.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/kw_workdir.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/local_context.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/docker/parser.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/log.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/man.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/manual.md +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/mapped_file.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/migrations.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/output.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/py.typed +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/refs.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/session.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/__init__.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/argmap.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/argspec.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/cache.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/completer.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/history.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/parser.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/repl.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/sources.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/shell/trie.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/skill.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/skill_body.md +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/types.py +0 -0
- {contree_cli-0.7.0 → contree_cli-0.8.0}/contree_cli/update_check.py +0 -0
|
@@ -328,37 +328,35 @@ def cmd_file_ls(args: FileListArgs) -> int | None:
|
|
|
328
328
|
if args.until is not None:
|
|
329
329
|
params["until"] = isoformat_datetime(args.until)
|
|
330
330
|
|
|
331
|
-
|
|
331
|
+
emitted = 0
|
|
332
|
+
hit_limit = False
|
|
333
|
+
with PaginatedFetcher(
|
|
332
334
|
client,
|
|
333
335
|
"/v1/files",
|
|
334
336
|
params,
|
|
335
337
|
lambda body: json.loads(body).get("files", []),
|
|
336
338
|
limit=args.limit,
|
|
337
339
|
concurrency=CONTREE_CONCURRENCY,
|
|
338
|
-
)
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
340
|
+
) as fetcher:
|
|
341
|
+
for page in fetcher:
|
|
342
|
+
for entry in page:
|
|
343
|
+
if emitted >= args.limit:
|
|
344
|
+
hit_limit = True
|
|
345
|
+
break
|
|
346
|
+
uuid_str = entry.get("uuid")
|
|
347
|
+
source = sources.get(uuid_str, "") if isinstance(uuid_str, str) else ""
|
|
348
|
+
if args.quiet:
|
|
349
|
+
formatter(
|
|
350
|
+
uuid=uuid_str,
|
|
351
|
+
sha256=entry.get("sha256", ""),
|
|
352
|
+
source=source,
|
|
353
|
+
)
|
|
354
|
+
else:
|
|
355
|
+
formatter(**{**entry, "source": source})
|
|
356
|
+
emitted += 1
|
|
357
|
+
formatter.flush()
|
|
358
|
+
if hit_limit:
|
|
346
359
|
break
|
|
347
|
-
uuid_str = entry.get("uuid")
|
|
348
|
-
source = sources.get(uuid_str, "") if isinstance(uuid_str, str) else ""
|
|
349
|
-
if args.quiet:
|
|
350
|
-
formatter(
|
|
351
|
-
uuid=uuid_str,
|
|
352
|
-
sha256=entry.get("sha256", ""),
|
|
353
|
-
source=source,
|
|
354
|
-
)
|
|
355
|
-
else:
|
|
356
|
-
formatter(**{**entry, "source": source})
|
|
357
|
-
emitted += 1
|
|
358
|
-
formatter.flush()
|
|
359
|
-
if hit_limit:
|
|
360
|
-
fetcher.stop()
|
|
361
|
-
break
|
|
362
360
|
|
|
363
361
|
if hit_limit:
|
|
364
362
|
logger.warning(
|
|
@@ -272,28 +272,26 @@ def cmd_images(args: ImagesArgs) -> None:
|
|
|
272
272
|
if args.until is not None:
|
|
273
273
|
base_params["until"] = isoformat_datetime(args.until)
|
|
274
274
|
|
|
275
|
-
|
|
275
|
+
emitted = 0
|
|
276
|
+
hit_limit = False
|
|
277
|
+
with PaginatedFetcher(
|
|
276
278
|
client,
|
|
277
279
|
"/v1/images",
|
|
278
280
|
base_params,
|
|
279
281
|
lambda body: json.loads(body)["images"],
|
|
280
282
|
limit=args.limit,
|
|
281
283
|
concurrency=CONTREE_CONCURRENCY,
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
284
|
+
) as fetcher:
|
|
285
|
+
for page in fetcher:
|
|
286
|
+
for image in page:
|
|
287
|
+
if emitted >= args.limit:
|
|
288
|
+
hit_limit = True
|
|
289
|
+
break
|
|
290
|
+
formatter(**image)
|
|
291
|
+
emitted += 1
|
|
292
|
+
formatter.flush()
|
|
293
|
+
if hit_limit:
|
|
290
294
|
break
|
|
291
|
-
formatter(**image)
|
|
292
|
-
emitted += 1
|
|
293
|
-
formatter.flush()
|
|
294
|
-
if hit_limit:
|
|
295
|
-
fetcher.stop()
|
|
296
|
-
break
|
|
297
295
|
|
|
298
296
|
if hit_limit:
|
|
299
297
|
logger.warning(
|
|
@@ -13,6 +13,7 @@ Subcommands:
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import argparse
|
|
16
|
+
import contextlib
|
|
16
17
|
import itertools
|
|
17
18
|
import json
|
|
18
19
|
import logging
|
|
@@ -21,7 +22,7 @@ from dataclasses import dataclass, field
|
|
|
21
22
|
from datetime import datetime
|
|
22
23
|
from typing import Any
|
|
23
24
|
|
|
24
|
-
from contree_cli import CLIENT, FORMATTER, ArgumentsProtocol, SetupResult
|
|
25
|
+
from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult
|
|
25
26
|
from contree_cli.cli.show import ShowArgs, cmd_show
|
|
26
27
|
from contree_cli.client import ApiError, ContreeClient, PaginatedFetcher
|
|
27
28
|
from contree_cli.output import OutputFormatter
|
|
@@ -393,31 +394,29 @@ def cmd_list(args: ListArgs) -> None:
|
|
|
393
394
|
base_params["until"] = isoformat_datetime(args.until)
|
|
394
395
|
|
|
395
396
|
limit = args.show_max
|
|
396
|
-
|
|
397
|
+
emitted = 0
|
|
398
|
+
hit_limit = False
|
|
399
|
+
with PaginatedFetcher(
|
|
397
400
|
client,
|
|
398
401
|
"/v1/operations",
|
|
399
402
|
base_params,
|
|
400
403
|
json.loads,
|
|
401
404
|
limit=limit,
|
|
402
405
|
concurrency=CONTREE_CONCURRENCY,
|
|
403
|
-
)
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
406
|
+
) as fetcher:
|
|
407
|
+
for page in fetcher:
|
|
408
|
+
for op in page:
|
|
409
|
+
if limit is not None and emitted >= limit:
|
|
410
|
+
hit_limit = True
|
|
411
|
+
break
|
|
412
|
+
if args.quiet:
|
|
413
|
+
print(op["uuid"])
|
|
414
|
+
else:
|
|
415
|
+
formatter(**op)
|
|
416
|
+
emitted += 1
|
|
417
|
+
formatter.flush()
|
|
418
|
+
if hit_limit:
|
|
411
419
|
break
|
|
412
|
-
if args.quiet:
|
|
413
|
-
print(op["uuid"])
|
|
414
|
-
else:
|
|
415
|
-
formatter(**op)
|
|
416
|
-
emitted += 1
|
|
417
|
-
formatter.flush()
|
|
418
|
-
if hit_limit:
|
|
419
|
-
fetcher.stop()
|
|
420
|
-
break
|
|
421
420
|
|
|
422
421
|
if hit_limit:
|
|
423
422
|
logger.warning(
|
|
@@ -498,11 +497,23 @@ def cmd_wait(args: WaitArgs) -> int | None:
|
|
|
498
497
|
exit_status = 0
|
|
499
498
|
sleep_time = 0.5
|
|
500
499
|
|
|
500
|
+
# Op cache is best-effort — `wait` doesn't strictly need the
|
|
501
|
+
# session store, and the cmd is reachable from contexts that don't
|
|
502
|
+
# set one (e.g. standalone scripts).
|
|
503
|
+
try:
|
|
504
|
+
store = SESSION_STORE.get()
|
|
505
|
+
except LookupError:
|
|
506
|
+
store = None
|
|
507
|
+
|
|
501
508
|
while pending and time.monotonic() < deadline:
|
|
502
509
|
for uuid in list(pending):
|
|
503
510
|
resp = client.get(f"/v1/operations/{uuid}")
|
|
504
511
|
op = json.loads(resp.read())
|
|
512
|
+
|
|
505
513
|
if op.get("status") in TERMINAL_STATUSES:
|
|
514
|
+
if store is not None:
|
|
515
|
+
with contextlib.suppress(Exception):
|
|
516
|
+
store.cache[(uuid, "operation")] = op
|
|
506
517
|
pending.discard(uuid)
|
|
507
518
|
exit_code = extract_exit_code(op)
|
|
508
519
|
formatter(
|
|
@@ -48,6 +48,7 @@ from __future__ import annotations
|
|
|
48
48
|
|
|
49
49
|
import argparse
|
|
50
50
|
import base64
|
|
51
|
+
import contextlib
|
|
51
52
|
import fnmatch
|
|
52
53
|
import functools
|
|
53
54
|
import io
|
|
@@ -62,9 +63,18 @@ import time
|
|
|
62
63
|
import uuid
|
|
63
64
|
from dataclasses import dataclass, field
|
|
64
65
|
from multiprocessing.pool import ThreadPool
|
|
66
|
+
from typing import Any
|
|
65
67
|
|
|
66
68
|
from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult
|
|
67
|
-
from contree_cli.client import
|
|
69
|
+
from contree_cli.client import (
|
|
70
|
+
RETRYABLE_NETWORK_ERRORS,
|
|
71
|
+
ApiError,
|
|
72
|
+
ContreeClient,
|
|
73
|
+
decode_event_chunk,
|
|
74
|
+
decode_stream,
|
|
75
|
+
iter_sse_events,
|
|
76
|
+
resolve_image,
|
|
77
|
+
)
|
|
68
78
|
from contree_cli.mapped_file import MAPPING_RULES, MappedFile
|
|
69
79
|
from contree_cli.output import (
|
|
70
80
|
DefaultFormatter,
|
|
@@ -505,11 +515,227 @@ def _build_payload(
|
|
|
505
515
|
return payload
|
|
506
516
|
|
|
507
517
|
|
|
518
|
+
TERMINAL_OP_STATUSES = frozenset({"SUCCESS", "FAILED", "CANCELLED"})
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@dataclass
|
|
522
|
+
class TerminalSummary:
|
|
523
|
+
"""Authoritative end-of-stream snapshot built from the SSE events
|
|
524
|
+
themselves — `cmd_run` consumes this directly instead of doing a
|
|
525
|
+
second ``GET /operations/{uuid}``.
|
|
526
|
+
|
|
527
|
+
If SSE ends without a `completion` frame but a between-attempt GET
|
|
528
|
+
detects the op is already terminal, the full op dict lands in
|
|
529
|
+
``fallback_op`` so `cmd_run` can use it directly."""
|
|
530
|
+
|
|
531
|
+
completion: dict[str, Any] | None = None
|
|
532
|
+
exit_event: dict[str, Any] | None = None
|
|
533
|
+
stdout: bytearray = field(default_factory=bytearray)
|
|
534
|
+
stderr: bytearray = field(default_factory=bytearray)
|
|
535
|
+
fallback_op: dict[str, Any] | None = None
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _check_terminal_via_get(
|
|
539
|
+
client: ContreeClient, op_uuid: str, summary: TerminalSummary
|
|
540
|
+
) -> bool:
|
|
541
|
+
"""Poll `GET /v1/operations/{uuid}`; if the op is in a terminal
|
|
542
|
+
status, park the full response on ``summary.fallback_op`` and
|
|
543
|
+
return True. Any GET failure or non-terminal status returns False
|
|
544
|
+
so the caller keeps retrying SSE."""
|
|
545
|
+
try:
|
|
546
|
+
resp = client.request("GET", f"/v1/operations/{op_uuid}")
|
|
547
|
+
op = json.loads(resp.read())
|
|
548
|
+
except (ApiError, ValueError) as exc:
|
|
549
|
+
logger.debug("terminal check GET failed: %s", exc)
|
|
550
|
+
return False
|
|
551
|
+
except RETRYABLE_NETWORK_ERRORS as exc:
|
|
552
|
+
logger.debug("terminal check GET failed: %s", exc)
|
|
553
|
+
return False
|
|
554
|
+
if isinstance(op, dict) and op.get("status") in TERMINAL_OP_STATUSES:
|
|
555
|
+
summary.fallback_op = op
|
|
556
|
+
return True
|
|
557
|
+
return False
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
TIGHT_LOOP_FLOOR = 0.5
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _stream_events_until_close(
|
|
564
|
+
client: ContreeClient,
|
|
565
|
+
op_uuid: str,
|
|
566
|
+
formatter: OutputFormatter,
|
|
567
|
+
) -> TerminalSummary:
|
|
568
|
+
"""Open `follow=1` SSE for *op_uuid* and write events to stdio,
|
|
569
|
+
transparently resuming on network drops / mid-stream errors using
|
|
570
|
+
``Last-Event-Id`` for replay-free continuation.
|
|
571
|
+
|
|
572
|
+
For ``DefaultFormatter``: ``stdout`` / ``stderr`` events go to
|
|
573
|
+
``sys.std*.buffer`` directly so the user sees output as it arrives.
|
|
574
|
+
For JSON formatters: data goes to ``log.debug`` so the JSON output
|
|
575
|
+
isn't polluted, but the chunks are accumulated into the returned
|
|
576
|
+
``TerminalSummary`` so the caller can render full stdout/stderr
|
|
577
|
+
without a follow-up GET.
|
|
578
|
+
|
|
579
|
+
Loops until one of: `completion` event received, GET-detected
|
|
580
|
+
terminal status, ``BrokenPipeError`` from a local stdio write, or
|
|
581
|
+
``KeyboardInterrupt``. Backoff between attempts is delegated to
|
|
582
|
+
``client.request`` — every SSE reconnect goes through its
|
|
583
|
+
``RETRY_DELAYS`` ladder before raising, so an extra streamer-level
|
|
584
|
+
ramp would double up. The one floor kept here (``TIGHT_LOOP_FLOOR``)
|
|
585
|
+
only kicks in when a cycle made no forward progress, guarding
|
|
586
|
+
against a server that returns immediate empty streams for an
|
|
587
|
+
executing op.
|
|
588
|
+
|
|
589
|
+
``BrokenPipeError`` from a local stdio write propagates unchanged —
|
|
590
|
+
it means the shell pipe closed and retrying cannot help; the
|
|
591
|
+
caller cancels the op and exits.
|
|
592
|
+
"""
|
|
593
|
+
is_default = isinstance(formatter, DefaultFormatter)
|
|
594
|
+
last_id: int = -1
|
|
595
|
+
summary = TerminalSummary()
|
|
596
|
+
|
|
597
|
+
while True:
|
|
598
|
+
headers: dict[str, str] | None = None
|
|
599
|
+
if last_id >= 0:
|
|
600
|
+
headers = {"Last-Event-Id": str(last_id)}
|
|
601
|
+
try:
|
|
602
|
+
resp = client.request(
|
|
603
|
+
"GET",
|
|
604
|
+
f"/v1/operations/{op_uuid}/events?follow=1",
|
|
605
|
+
headers=headers,
|
|
606
|
+
)
|
|
607
|
+
except ApiError as exc:
|
|
608
|
+
logger.debug("SSE connect failed after client retries: %s", exc)
|
|
609
|
+
if _check_terminal_via_get(client, op_uuid, summary):
|
|
610
|
+
return summary
|
|
611
|
+
continue
|
|
612
|
+
except RETRYABLE_NETWORK_ERRORS as exc:
|
|
613
|
+
logger.debug("SSE connect network error after client retries: %s", exc)
|
|
614
|
+
if _check_terminal_via_get(client, op_uuid, summary):
|
|
615
|
+
return summary
|
|
616
|
+
continue
|
|
617
|
+
|
|
618
|
+
events_before = last_id
|
|
619
|
+
try:
|
|
620
|
+
for ev in iter_sse_events(resp):
|
|
621
|
+
ev_id = ev.get("id")
|
|
622
|
+
if isinstance(ev_id, int):
|
|
623
|
+
last_id = ev_id
|
|
624
|
+
ev_type = ev.get("type")
|
|
625
|
+
data = ev.get("data")
|
|
626
|
+
match ev_type:
|
|
627
|
+
case "stdout":
|
|
628
|
+
chunk = decode_event_chunk(data)
|
|
629
|
+
summary.stdout.extend(chunk)
|
|
630
|
+
if is_default:
|
|
631
|
+
sys.stdout.buffer.write(chunk)
|
|
632
|
+
sys.stdout.buffer.flush()
|
|
633
|
+
case "stderr":
|
|
634
|
+
chunk = decode_event_chunk(data)
|
|
635
|
+
summary.stderr.extend(chunk)
|
|
636
|
+
if is_default:
|
|
637
|
+
sys.stderr.buffer.write(chunk)
|
|
638
|
+
sys.stderr.buffer.flush()
|
|
639
|
+
case "exit":
|
|
640
|
+
# spid=1 is the main process — its exit code/timed_out
|
|
641
|
+
# drive the CLI's own exit code.
|
|
642
|
+
if ev.get("spid") == 1:
|
|
643
|
+
summary.exit_event = ev
|
|
644
|
+
logger.debug("event: %s", ev)
|
|
645
|
+
case "sse_error":
|
|
646
|
+
logger.warning(
|
|
647
|
+
"server-side stream error (last_id=%s): %s",
|
|
648
|
+
last_id,
|
|
649
|
+
ev.get("message"),
|
|
650
|
+
)
|
|
651
|
+
case "completion":
|
|
652
|
+
# Authoritative terminal frame — don't wait for the server
|
|
653
|
+
# to close, return the summary for the caller.
|
|
654
|
+
summary.completion = ev
|
|
655
|
+
logger.debug("event: %s", ev)
|
|
656
|
+
return summary
|
|
657
|
+
case _:
|
|
658
|
+
logger.debug("event: %s", ev)
|
|
659
|
+
except BrokenPipeError:
|
|
660
|
+
# Local stdout/stderr was closed by the shell (e.g. piping
|
|
661
|
+
# into `head`). Retrying cannot help — the caller cancels
|
|
662
|
+
# the op and exits.
|
|
663
|
+
raise
|
|
664
|
+
except RETRYABLE_NETWORK_ERRORS as exc:
|
|
665
|
+
logger.debug("SSE stream broken (last_id=%s): %s", last_id, exc)
|
|
666
|
+
finally:
|
|
667
|
+
with contextlib.suppress(Exception):
|
|
668
|
+
resp.close()
|
|
669
|
+
|
|
670
|
+
if _check_terminal_via_get(client, op_uuid, summary):
|
|
671
|
+
return summary
|
|
672
|
+
|
|
673
|
+
# No forward progress this cycle → briefly floor the loop so a
|
|
674
|
+
# server that keeps returning immediate EOFs doesn't spin us.
|
|
675
|
+
if last_id == events_before:
|
|
676
|
+
time.sleep(TIGHT_LOOP_FLOOR)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _build_op_from_summary(op_uuid: str, summary: TerminalSummary) -> dict[str, Any]:
|
|
680
|
+
"""Synthesize a `GET /operations/{uuid}` shape from the SSE terminal
|
|
681
|
+
summary — completion event drives status / error / duration / image
|
|
682
|
+
metadata; exit event drives exit_code + timed_out; stdout/stderr
|
|
683
|
+
are reassembled from the streamed chunks.
|
|
684
|
+
|
|
685
|
+
Lets `cmd_run` avoid a second HTTP round-trip on the happy path
|
|
686
|
+
while keeping the downstream `_display_operation` consumers
|
|
687
|
+
(default and JSON formatters) untouched."""
|
|
688
|
+
assert summary.completion is not None
|
|
689
|
+
completion_data = summary.completion.get("data") or {}
|
|
690
|
+
status = completion_data.get("status")
|
|
691
|
+
state: dict[str, Any] = {}
|
|
692
|
+
if summary.exit_event:
|
|
693
|
+
exit_data = summary.exit_event.get("data") or {}
|
|
694
|
+
if "code" in exit_data:
|
|
695
|
+
state["exit_code"] = int(exit_data["code"])
|
|
696
|
+
if "timed_out" in exit_data:
|
|
697
|
+
state["timed_out"] = bool(exit_data["timed_out"])
|
|
698
|
+
result_image_uuid = completion_data.get("result_image_uuid")
|
|
699
|
+
return {
|
|
700
|
+
"uuid": op_uuid,
|
|
701
|
+
"kind": "instance",
|
|
702
|
+
"status": status,
|
|
703
|
+
"error": completion_data.get("error"),
|
|
704
|
+
"duration": (completion_data.get("duration_ms") or 0) / 1000.0,
|
|
705
|
+
"image_size": completion_data.get("image_size_bytes"),
|
|
706
|
+
"result_image_uuid": result_image_uuid,
|
|
707
|
+
"metadata": {
|
|
708
|
+
"result": {
|
|
709
|
+
"stdout": {
|
|
710
|
+
"value": bytes(summary.stdout).decode("utf-8", errors="replace"),
|
|
711
|
+
"encoding": "ascii",
|
|
712
|
+
"truncated": False,
|
|
713
|
+
},
|
|
714
|
+
"stderr": {
|
|
715
|
+
"value": bytes(summary.stderr).decode("utf-8", errors="replace"),
|
|
716
|
+
"encoding": "ascii",
|
|
717
|
+
"truncated": False,
|
|
718
|
+
},
|
|
719
|
+
"state": state or None,
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
"result": {"image": result_image_uuid, "tag": None},
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
|
|
508
726
|
def _display_operation(
|
|
509
727
|
op: dict[str, object],
|
|
510
728
|
formatter: OutputFormatter,
|
|
729
|
+
live_streamed: bool = False,
|
|
511
730
|
) -> None:
|
|
512
|
-
"""Display an operation result using the given formatter.
|
|
731
|
+
"""Display an operation result using the given formatter.
|
|
732
|
+
|
|
733
|
+
``live_streamed=True`` means the SSE path already wrote stdout/
|
|
734
|
+
stderr to stdio incrementally — DefaultFormatter then has nothing
|
|
735
|
+
left to print. ``live_streamed=False`` is the GET-fallback path
|
|
736
|
+
(legacy server or aborted stream) — DefaultFormatter prints
|
|
737
|
+
sanitized stdout/stderr from the operation's metadata blob.
|
|
738
|
+
"""
|
|
513
739
|
result = op.get("result") or {}
|
|
514
740
|
assert isinstance(result, dict)
|
|
515
741
|
metadata = op.get("metadata") or {}
|
|
@@ -544,10 +770,14 @@ def _display_operation(
|
|
|
544
770
|
"only json/json-pretty/default are supported"
|
|
545
771
|
)
|
|
546
772
|
|
|
547
|
-
|
|
773
|
+
if live_streamed:
|
|
774
|
+
# stdout/stderr already streamed live via SSE — nothing more to print.
|
|
775
|
+
return
|
|
776
|
+
|
|
777
|
+
# Legacy / fallback path (no completion event received): print
|
|
778
|
+
# sanitized stdout/stderr from op metadata.
|
|
548
779
|
stdout = _BREAKING_ESC_RE.sub("", decode_stream(instance_result.get("stdout")))
|
|
549
780
|
stderr = _BREAKING_ESC_RE.sub("", decode_stream(instance_result.get("stderr")))
|
|
550
|
-
|
|
551
781
|
if stdout:
|
|
552
782
|
sys.stdout.write(stdout)
|
|
553
783
|
if not stdout.endswith("\n"):
|
|
@@ -688,19 +918,27 @@ def cmd_run(args: RunArgs) -> int | None:
|
|
|
688
918
|
formatter.flush()
|
|
689
919
|
return None
|
|
690
920
|
|
|
691
|
-
# 5.
|
|
692
|
-
|
|
921
|
+
# 5. Stream events (follow=1) — write stdout/stderr to stdtio as they
|
|
922
|
+
# come, log other events at debug, accumulate the terminal frame.
|
|
923
|
+
store = SESSION_STORE.get()
|
|
693
924
|
try:
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
925
|
+
summary = _stream_events_until_close(client, op_uuid, formatter)
|
|
926
|
+
if summary.completion is not None:
|
|
927
|
+
# Authoritative terminal frame from the server — build the
|
|
928
|
+
# full op dict from the SSE events themselves, no GET.
|
|
929
|
+
op = _build_op_from_summary(op_uuid, summary)
|
|
930
|
+
elif summary.fallback_op is not None:
|
|
931
|
+
# SSE couldn't deliver `completion`, but a GET between
|
|
932
|
+
# retries confirmed the op is terminal — use that dict.
|
|
933
|
+
op = summary.fallback_op
|
|
934
|
+
else:
|
|
935
|
+
# Safety net: streamer normally loops until either
|
|
936
|
+
# completion or a terminal GET, so this path is only hit
|
|
937
|
+
# in tests where the stub queue drains early.
|
|
697
938
|
resp = client.get(f"/v1/operations/{op_uuid}")
|
|
698
939
|
op = json.loads(resp.read())
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
time.sleep(sleep_time)
|
|
702
|
-
if sleep_time < 5:
|
|
703
|
-
sleep_time += sleep_time
|
|
940
|
+
cache_key = (op_uuid, "operation")
|
|
941
|
+
store.cache[cache_key] = op
|
|
704
942
|
except KeyboardInterrupt:
|
|
705
943
|
try:
|
|
706
944
|
client.delete(f"/v1/operations/{op_uuid}")
|
|
@@ -708,6 +946,17 @@ def cmd_run(args: RunArgs) -> int | None:
|
|
|
708
946
|
except (ApiError, KeyboardInterrupt, OSError):
|
|
709
947
|
pass
|
|
710
948
|
raise
|
|
949
|
+
except BrokenPipeError:
|
|
950
|
+
# Local stdout/stderr was closed (e.g. `contree run | head`).
|
|
951
|
+
# Cancel the op, silence further stdio writes, then exit 141
|
|
952
|
+
# so callers see the SIGPIPE convention (128 + 13).
|
|
953
|
+
with contextlib.suppress(ApiError, OSError):
|
|
954
|
+
client.delete(f"/v1/operations/{op_uuid}")
|
|
955
|
+
with contextlib.suppress(OSError):
|
|
956
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
957
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
958
|
+
os.close(devnull)
|
|
959
|
+
raise SystemExit(141) from None
|
|
711
960
|
|
|
712
961
|
# 6. Cache terminal operation result
|
|
713
962
|
store.cache[(op_uuid, "operation")] = op
|
|
@@ -735,7 +984,7 @@ def cmd_run(args: RunArgs) -> int | None:
|
|
|
735
984
|
)
|
|
736
985
|
|
|
737
986
|
# 7. Display result
|
|
738
|
-
_display_operation(op, formatter)
|
|
987
|
+
_display_operation(op, formatter, live_streamed=summary.completion is not None)
|
|
739
988
|
|
|
740
989
|
result = op.get("result") or {}
|
|
741
990
|
assert isinstance(result, dict)
|
|
@@ -61,7 +61,7 @@ def cmd_show(args: ShowArgs) -> int | None:
|
|
|
61
61
|
|
|
62
62
|
cache_key = (op_uuid, "operation")
|
|
63
63
|
cached = store.cache.get(cache_key)
|
|
64
|
-
if cached
|
|
64
|
+
if isinstance(cached, dict) and cached.get("status") in TERMINAL:
|
|
65
65
|
op = cast(dict[str, Any], cached)
|
|
66
66
|
else:
|
|
67
67
|
resp = client.get(f"/v1/operations/{op_uuid}")
|