alissa-tools-github-revloop 0.31.10__tar.gz → 0.31.12__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 (39) hide show
  1. {alissa_tools_github_revloop-0.31.10/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.12}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/fleet_vitals.py +217 -5
  3. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/loop.py +211 -7
  4. alissa_tools_github_revloop-0.31.12/src/main/alissa/tools/github/revloop/parking.py +222 -0
  5. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/state.py +124 -0
  6. alissa_tools_github_revloop-0.31.12/src/main/alissa/tools/github/revloop/version +1 -0
  7. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/webui/__main__.py +20 -1
  8. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/webui/server.py +114 -2
  9. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/webui/sources.py +77 -3
  10. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  11. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +1 -0
  12. alissa_tools_github_revloop-0.31.10/src/main/alissa/tools/github/revloop/version +0 -1
  13. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/LICENSE +0 -0
  14. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/MANIFEST.in +0 -0
  15. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/NOTICE +0 -0
  16. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/README.md +0 -0
  17. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/requirements.txt +0 -0
  18. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/setup.cfg +0 -0
  19. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/setup.py +0 -0
  20. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  21. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  22. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  23. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  24. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  25. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/config.py +0 -0
  26. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  27. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  28. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  29. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
  30. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  31. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  32. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/version.py +0 -0
  33. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  34. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  35. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  36. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  37. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  38. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  39. {alissa_tools_github_revloop-0.31.10 → alissa_tools_github_revloop-0.31.12}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.10
3
+ Version: 0.31.12
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -41,16 +41,43 @@ Design rules, all load-bearing:
41
41
  user has not activated the loop app) warns ONCE per boot with the activate
42
42
  URL and logs at DEBUG after, because it is a fixed condition the operator
43
43
  resolves out of band, and the next pass after activation lands on its own.
44
+
45
+ Seat parking (issue #157, studio.alissa.app seat-parking design §2.2/§2.4)
46
+ adds the `idle` block -- the seat's own idle verdict (`parking.idle_block`),
47
+ with `drainedAt` while a drain holds -- so Studio can re-check a park against
48
+ the seat's OWN snapshot. The console's drain pushes one snapshot before it
49
+ answers (`FleetVitalsPusher.push_now`). Until Studio's schema carries the
50
+ block (the design's lane L3), a strict 400 naming `idle` makes the pusher
51
+ resend without it and leave it out for IDLE_REPROBE_S: the vitals the Factory
52
+ already renders never go dark over a field nobody reads yet.
53
+
54
+ The managed fleet's smoke (issue #159, studio.alissa.app provisioner design
55
+ §2.7, lane L9) adds the `config` block -- what this seat DERIVED, in its own
56
+ words: the feed owner actor id, the effective review allowlist, the reviewer
57
+ login(s) it requests (none: the reviewer seat is the one requested) and its
58
+ own GitHub login, the reviewer identity SM4 compares with the rev seat
59
+ credential. Every value is an identity or a name, never a credential: the
60
+ block is built from the config the watcher RUNS on, not from the
61
+ environment, and any value carrying a secret the process holds
62
+ (`SECRET_ENV`, plus the variable `reviewer_token_env` names) is blanked
63
+ before it can leave. The daemon's own pass sends it; the console's
64
+ out-of-pass push leaves it out (that process never resolves the feed
65
+ authority nor refreshes the allowlist, so its answer would be a different
66
+ one). A Studio whose strict schema predates the block gets the `idle`
67
+ treatment: resent without it, offered again after BLOCK_REPROBE_S.
44
68
  """
45
69
 
46
70
  from __future__ import annotations
47
71
 
48
72
  import json
49
73
  import logging
74
+ import os
75
+ import re
50
76
  import time
51
77
  from datetime import datetime, timezone
52
- from typing import TYPE_CHECKING, Any, Callable
78
+ from typing import TYPE_CHECKING, Any, Callable, Mapping, Sequence
53
79
 
80
+ from . import parking
54
81
  from .alissa import parse_session_name, session_repo_slug
55
82
  from .alissa_client import (
56
83
  NOT_ACTIVATED,
@@ -58,7 +85,7 @@ from .alissa_client import (
58
85
  AlissaClient,
59
86
  AlissaError,
60
87
  )
61
- from .config import Config
88
+ from .config import REPOS_BOWS, Config
62
89
  from .proc import CommandError, run as proc_run
63
90
 
64
91
  if TYPE_CHECKING: # pragma: no cover - typing only
@@ -87,6 +114,32 @@ VITALS_PUSHED = "pushed"
87
114
  VITALS_SKIPPED = "skipped"
88
115
  VITALS_FAILED = "failed"
89
116
 
117
+ # The blocks Studio's schema gained after the base contract, each in its own
118
+ # lane: `idle` (seat parking L3) and `config` (provisioner L5). A strict 400
119
+ # naming one of them is an old Studio, not a bad snapshot.
120
+ OPTIONAL_BLOCKS = ("idle", "config")
121
+
122
+ # How long a snapshot leaves an optional block out after Studio refused it as
123
+ # an unknown key, before it offers the block again. An hour: one extra POST
124
+ # an hour against an old Studio, and a newly deployed one starts receiving
125
+ # the block within the hour.
126
+ BLOCK_REPROBE_S = 3600.0
127
+ IDLE_REPROBE_S = BLOCK_REPROBE_S
128
+
129
+ # The `config` block's keys (provisioner design §2.7 / lane L9), in order.
130
+ CONFIG_KEYS = ("feedOwnerActorId", "repos", "reviewersRequested", "ghLogin")
131
+
132
+ # The credentials a revloop container holds (the provisioner's variable
133
+ # collection, design §2.6, plus gh's own fallback name); the variable a
134
+ # `reviewer_token_env` names is added per config. No `config` value may carry
135
+ # one. A value shorter than SECRET_MIN is not a credential and is not matched
136
+ # (a passcode of "1" must not blank every login holding a 1).
137
+ SECRET_ENV = (
138
+ "GH_TOKEN", "GITHUB_TOKEN", "ALISSA_API_TOKEN", "ALISSA_UI_PASSCODE",
139
+ "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
140
+ )
141
+ SECRET_MIN = 8
142
+
90
143
  # The operator's lever per inbox kind, in the Factory's `lever` slot. The
91
144
  # cap-out and the stability hold are both lifted by the same re-entry ack;
92
145
  # a stalled episode is the console's retry-now (or a reap).
@@ -242,6 +295,7 @@ def build_snapshot(
242
295
  queue_depth: "int | None",
243
296
  repos: "tuple[str, ...]" = (),
244
297
  now: "int | float | None" = None,
298
+ config: "Mapping | None" = None,
245
299
  ) -> dict:
246
300
  """The contract body for this pass, off the console's builders.
247
301
 
@@ -252,6 +306,14 @@ def build_snapshot(
252
306
  optional field degrades to null on its own -- a broken tmux does not
253
307
  blank the memory split, and vice versa -- and `kpis` is always null (an
254
308
  orcloop-only section).
309
+
310
+ `idle` (seat parking, issue #157) is the seat's idle block: owed and
311
+ timers from the newest poll snapshot, live / managed from the SAME roster
312
+ read as `sessions`, queued from the alissa queue, and `drainedAt` while
313
+ a drain holds.
314
+
315
+ `config` is the seat's `config_block`, or None to leave the key out (the
316
+ console's push, which has no derived answer to give).
255
317
  """
256
318
  stamp = time.time() if now is None else now
257
319
  snaps = sources.snapshots(DURATION_POINTS)
@@ -277,7 +339,13 @@ def build_snapshot(
277
339
  )
278
340
  session_list = [_session_row(r, repos) for r in ordered[:LIST_CAP]]
279
341
  inbox = [_inbox_row(item) for item in sources.inbox()["live"][:LIST_CAP]]
280
- return {
342
+ live, managed = parking.roster_counts(rows)
343
+ idle = parking.idle_block(
344
+ snaps[0] if snaps else None,
345
+ live=live, managed=managed, queued=sources.queued(),
346
+ drain=sources.drain(), now=stamp,
347
+ )
348
+ snapshot = {
281
349
  "schemaVersion": SCHEMA_VERSION,
282
350
  "seat": SEAT,
283
351
  "asOf": iso_utc(stamp),
@@ -297,7 +365,74 @@ def build_snapshot(
297
365
  "queueDepth": _int_or_none(queue_depth),
298
366
  "kpis": None,
299
367
  "inbox": inbox,
368
+ "idle": idle,
369
+ }
370
+ if config is not None:
371
+ snapshot["config"] = dict(config)
372
+ return snapshot
373
+
374
+
375
+ def _carries_secret(value: str, secrets: "Sequence[str]") -> bool:
376
+ return any(secret in value for secret in secrets)
377
+
378
+
379
+ def config_block(
380
+ config: Config,
381
+ *,
382
+ gh_login: "str | None",
383
+ environ: "Mapping[str, str] | None" = None,
384
+ ) -> dict:
385
+ """The seat's own word on what it derived (issue #159): four fields,
386
+ each from the config the daemon is RUNNING on, never from the raw env.
387
+
388
+ * `feedOwnerActorId` -- the bodies-of-work authority under
389
+ `repos_source: bows` (the token's own actor, resolved at boot, unless
390
+ `bow_owners` names one). Null in static mode (no feed is read) and when
391
+ several owners are trusted (there is no single answer to quote).
392
+ * `repos` -- the effective allowlist, sorted: under bows the derived
393
+ union the last refresh bound, in static mode the configured list
394
+ (empty there means "every repo that requests me").
395
+ * `reviewersRequested` -- always empty: the reviewer seat requests no
396
+ reviewer, it IS the one requested. The key is kept so every seat's
397
+ block has one shape.
398
+ * `ghLogin` -- the reviewer identity the gh credential resolves to (the
399
+ login round counting and every posted review are held to); null when
400
+ unknown.
401
+
402
+ `environ` (default: the process env) supplies the secrets to scan for:
403
+ a value carrying one is blanked -- a list entry dropped, a scalar
404
+ nulled -- and named in a WARNING by field only."""
405
+ env = os.environ if environ is None else environ
406
+ names = SECRET_ENV + (
407
+ (config.reviewer_token_env,) if config.reviewer_token_env else ()
408
+ )
409
+ secrets = [
410
+ v for k in names if len(v := (env.get(k) or "").strip()) >= SECRET_MIN
411
+ ]
412
+ owners = config.bow_owners if config.repos_source == REPOS_BOWS else ()
413
+ block: dict = {
414
+ "feedOwnerActorId": owners[0] if len(owners) == 1 else None,
415
+ "repos": sorted(config.repos, key=str.casefold),
416
+ "reviewersRequested": [],
417
+ "ghLogin": gh_login or None,
300
418
  }
419
+ for key in CONFIG_KEYS:
420
+ value = block[key]
421
+ if isinstance(value, list):
422
+ kept = [v for v in value if not _carries_secret(v, secrets)]
423
+ blanked = len(kept) != len(value)
424
+ block[key] = kept
425
+ else:
426
+ blanked = value is not None and _carries_secret(value, secrets)
427
+ if blanked:
428
+ block[key] = None
429
+ if blanked:
430
+ log.warning(
431
+ "fleet-vitals: the config block's %s carried a credential "
432
+ "this process holds — blanked before sending; check the "
433
+ "variable it is derived from", key,
434
+ )
435
+ return block
301
436
 
302
437
 
303
438
  def body_bytes(snapshot: dict) -> int:
@@ -356,6 +491,7 @@ class FleetVitalsPusher:
356
491
  client: AlissaClient,
357
492
  *,
358
493
  clock: Callable[[], float] = time.time,
494
+ config_reader: "Callable[[], Mapping | None] | None" = None,
359
495
  ):
360
496
  self.config = config
361
497
  self._sources = sources
@@ -366,6 +502,12 @@ class FleetVitalsPusher:
366
502
  # every later refusal is a DEBUG line. The push itself is still
367
503
  # attempted every pass -- the pass after activation lands on its own.
368
504
  self._not_activated_warned = False
505
+ # The `config` block's source (the daemon's `_vitals_config`), or
506
+ # None -- the console's pusher -- to leave the block out.
507
+ self._config_reader = config_reader
508
+ # Per optional block: until when it is left out (Studio refused it
509
+ # as an unknown key). Absent = offer it.
510
+ self._refused_until: "dict[str, float]" = {}
369
511
 
370
512
  def build(self, *, heartbeat_at: "int | float", queue_depth: "int | None") -> dict:
371
513
  """This pass's contract body, fitted to the API's caps."""
@@ -376,8 +518,24 @@ class FleetVitalsPusher:
376
518
  queue_depth=queue_depth,
377
519
  repos=self.config.repos,
378
520
  now=self._clock(),
521
+ config=self._config(),
379
522
  ))
380
523
 
524
+ def _config(self) -> "Mapping | None":
525
+ """The `config` block, or None. A reader that fails costs the block,
526
+ never the snapshot: the rest of the vitals still go out."""
527
+ if self._config_reader is None:
528
+ return None
529
+ try:
530
+ return self._config_reader()
531
+ except Exception as exc:
532
+ log.warning(
533
+ "fleet-vitals: could not derive the config block (%s: %s) — "
534
+ "pushing the snapshot without it",
535
+ type(exc).__name__, exc,
536
+ )
537
+ return None
538
+
381
539
  def push_once(
382
540
  self, *, heartbeat_at: "int | float", queue_depth: "int | None" = None
383
541
  ) -> str:
@@ -400,9 +558,41 @@ class FleetVitalsPusher:
400
558
  type(exc).__name__, exc,
401
559
  )
402
560
  return VITALS_FAILED
561
+ now = self._clock()
562
+ for key, until in self._refused_until.items():
563
+ if now < until:
564
+ snapshot.pop(key, None)
403
565
  if self.config.dry_run:
404
566
  log.info("[dry-run] would push fleet vitals (%s)", describe(snapshot))
405
567
  return VITALS_SKIPPED
568
+ return self._post(snapshot)
569
+
570
+ def push_now(self) -> str:
571
+ """The console's out-of-pass push (the drain handshake, issue #157):
572
+ the same snapshot, its `heartbeatAt` the daemon's LAST completed pass
573
+ -- the console completes none of its own, and stamping now would read
574
+ as a heartbeat the loop never gave -- and `asOf` now. `queueDepth` is
575
+ null: the slot queue lives in the daemon's memory, and the console
576
+ cannot say. One of VITALS_PUSHED, VITALS_SKIPPED or VITALS_FAILED."""
577
+ snaps = self._sources.snapshots(1)
578
+ heartbeat = snaps[0]["ts"] if snaps else self._clock()
579
+ return self.push_once(heartbeat_at=heartbeat, queue_depth=None)
580
+
581
+ @staticmethod
582
+ def _refused_blocks(exc: AlissaError, snapshot: dict) -> "list[str]":
583
+ """The optional blocks of `snapshot` a 400 names: a Studio whose
584
+ strict schema predates them (seat parking's lane L3 adds `idle`, the
585
+ provisioner's lane L5 adds `config`). A word match, so `config` is
586
+ not read into an unrelated message that merely contains it."""
587
+ if exc.status != 400:
588
+ return []
589
+ detail = str(exc.detail)
590
+ return [
591
+ key for key in OPTIONAL_BLOCKS
592
+ if key in snapshot and re.search(rf"\b{key}\b", detail)
593
+ ]
594
+
595
+ def _post(self, snapshot: dict) -> str:
406
596
  try:
407
597
  result = self._client.post_fleet_vitals(snapshot)
408
598
  except AlissaAuthError as exc:
@@ -412,6 +602,22 @@ class FleetVitalsPusher:
412
602
  self._warn_failed(exc)
413
603
  return VITALS_FAILED
414
604
  except AlissaError as exc:
605
+ refused = self._refused_blocks(exc, snapshot)
606
+ if refused:
607
+ until = self._clock() + BLOCK_REPROBE_S
608
+ for key in refused:
609
+ self._refused_until[key] = until
610
+ log.warning(
611
+ "fleet-vitals: Studio refused the %s block(s) (status "
612
+ "%s: %s) — its schema predates them; resending without "
613
+ "them, and leaving them out for %d min",
614
+ ", ".join(f"`{k}`" for k in refused), exc.status,
615
+ exc.detail, int(BLOCK_REPROBE_S // 60),
616
+ )
617
+ trimmed = {
618
+ k: v for k, v in snapshot.items() if k not in refused
619
+ }
620
+ return self._post(trimmed)
415
621
  self._warn_failed(exc)
416
622
  return VITALS_FAILED
417
623
  log.info(
@@ -481,6 +687,7 @@ def build_pusher(
481
687
  *,
482
688
  github: object = None,
483
689
  endpoint: "str | None" = None,
690
+ config_reader: "Callable[[], Mapping | None] | None" = None,
484
691
  ) -> FleetVitalsPusher:
485
692
  """The pusher the watcher wires in when `fleet_vitals_enabled` is on.
486
693
 
@@ -491,7 +698,9 @@ def build_pusher(
491
698
  own `ALISSA_API_TOKEN`); its absence surfaces as the pusher's WARN, never
492
699
  at construction, because a daemon must boot and poll whether or not
493
700
  vitals can authenticate. The import is local because `sources` imports
494
- `loop`, which imports this module.
701
+ `loop`, which imports this module. `config_reader` is the daemon's
702
+ `config` block source (issue #159); the console builds its own pusher
703
+ without one.
495
704
  """
496
705
  from .version import version
497
706
  from .webui.sources import Sources
@@ -501,4 +710,7 @@ def build_pusher(
501
710
  running_version=version.value,
502
711
  run=_console_runner(github),
503
712
  )
504
- return FleetVitalsPusher(config, sources, AlissaClient(base=endpoint))
713
+ return FleetVitalsPusher(
714
+ config, sources, AlissaClient(base=endpoint),
715
+ config_reader=config_reader,
716
+ )