alissa-tools-github-devloop 0.8.36__tar.gz → 0.8.38__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_devloop-0.8.36/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.38}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/fleet_vitals.py +201 -4
  3. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/loop.py +288 -9
  4. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/loop_events.py +1 -0
  5. alissa_tools_github_devloop-0.8.38/src/main/alissa/tools/github/devloop/parking.py +205 -0
  6. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/state.py +133 -0
  7. alissa_tools_github_devloop-0.8.38/src/main/alissa/tools/github/devloop/version +1 -0
  8. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/webui/__main__.py +24 -1
  9. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/webui/server.py +124 -2
  10. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/webui/sources.py +51 -1
  11. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  12. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
  13. alissa_tools_github_devloop-0.8.36/src/main/alissa/tools/github/devloop/version +0 -1
  14. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/LICENSE +0 -0
  15. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/MANIFEST.in +0 -0
  16. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/NOTICE +0 -0
  17. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/README.md +0 -0
  18. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/requirements.txt +0 -0
  19. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/setup.cfg +0 -0
  20. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/setup.py +0 -0
  21. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  22. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  23. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  24. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  25. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  26. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/config.py +0 -0
  27. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  28. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
  29. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  30. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  31. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/prompts.py +0 -0
  32. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/version.py +0 -0
  33. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  34. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  35. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  36. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  37. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  38. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  39. {alissa_tools_github_devloop-0.8.36 → alissa_tools_github_devloop-0.8.38}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.36
3
+ Version: 0.8.38
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -36,18 +36,43 @@ The roster rule the contract cares most about: `sessions` and `sessionList`
36
36
  are `null` — not `{live: 0, managed: 0}` — when the listing could not be
37
37
  read. A machine whose tmux blinked is not an idle machine, and the Factory
38
38
  must be able to tell the two apart.
39
+
40
+ Seat parking (issue #153, studio.alissa.app seat-parking design §2.2/§2.4)
41
+ adds the `idle` block — the seat's own idle verdict (`parking.idle_block`),
42
+ with `drainedAt` while a drain holds — so Studio can re-check a park against
43
+ the seat's OWN snapshot. The console's drain pushes one snapshot before it
44
+ answers (`FleetVitalsPusher.push_now`). Until Studio's schema carries the
45
+ block (the design's lane L3), a strict 400 naming `idle` makes the pusher
46
+ resend without it and leave it out for IDLE_REPROBE_S: the vitals the Factory
47
+ already renders never go dark over a field nobody reads yet.
48
+
49
+ The managed fleet's smoke (issue #155, studio.alissa.app provisioner design
50
+ §2.7, lane L9) adds the `config` block -- what this seat DERIVED, in its own
51
+ words: the feed owner actor id, the effective repository allowlist, the
52
+ reviewer login(s) it requests and its own GitHub login. SM2-SM4 compare it to
53
+ what Studio provisioned. Four fields, every one of them an identity or a
54
+ name, never a credential: the block is built from typed config fields, not
55
+ from the environment, and any value that carries a secret the process holds
56
+ (`SECRET_ENV`) is blanked before it can leave. The daemon's own pass sends
57
+ it; the console's out-of-pass push leaves it out (that process never
58
+ resolves the feed authority nor refreshes the allowlist, so its answer would
59
+ be a different one). A Studio whose strict schema predates the block gets
60
+ the `idle` treatment: resent without it, offered again after BLOCK_REPROBE_S.
39
61
  """
40
62
 
41
63
  from __future__ import annotations
42
64
 
43
65
  import json
44
66
  import logging
67
+ import os
68
+ import re
45
69
  import time
46
70
  from datetime import datetime, timezone
47
71
  from typing import Callable, Mapping, Sequence
48
72
 
73
+ from . import parking
49
74
  from .alissa_client import AlissaAuthError, AlissaClient, AlissaError
50
- from .config import Config
75
+ from .config import REPOS_BOWS, Config
51
76
  from .version import version as running_version
52
77
  from .webui import sources as sources_mod
53
78
  from .webui import sysinfo
@@ -74,6 +99,31 @@ PUSHED = "pushed"
74
99
  SKIPPED = "skipped"
75
100
  FAILED = "failed"
76
101
 
102
+ # The blocks Studio's schema gained after the base contract, each in its own
103
+ # lane: `idle` (seat parking L3) and `config` (provisioner L5). A strict 400
104
+ # naming one of them is an old Studio, not a bad snapshot.
105
+ OPTIONAL_BLOCKS = ("idle", "config")
106
+
107
+ # How long a snapshot leaves an optional block out after Studio refused it as
108
+ # an unknown key, before it offers the block again. An hour: one extra POST
109
+ # an hour against an old Studio, and a newly deployed one starts receiving
110
+ # the block within the hour.
111
+ BLOCK_REPROBE_S = 3600.0
112
+ IDLE_REPROBE_S = BLOCK_REPROBE_S
113
+
114
+ # The `config` block's keys (provisioner design §2.7 / lane L9), in order.
115
+ CONFIG_KEYS = ("feedOwnerActorId", "repos", "reviewersRequested", "ghLogin")
116
+
117
+ # The credentials a devloop container holds (the provisioner's variable
118
+ # collection, design §2.6, plus gh's own fallback name). No `config` value
119
+ # may carry one; a shorter value than SECRET_MIN is not a credential and is
120
+ # not matched (a passcode of "1" must not blank every login holding a 1).
121
+ SECRET_ENV = (
122
+ "GH_TOKEN", "GITHUB_TOKEN", "ALISSA_API_TOKEN", "ALISSA_UI_PASSCODE",
123
+ "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN",
124
+ )
125
+ SECRET_MIN = 8
126
+
77
127
 
78
128
  def _session_grammar() -> "Callable[[str], object]":
79
129
  """The daemon's own session-name parser, resolved late.
@@ -189,6 +239,9 @@ def build_snapshot(
189
239
  now: "float | int | None" = None,
190
240
  memory: "Mapping | None" = None,
191
241
  parse_session: "Callable[[str], object] | None" = None,
242
+ queued: "int | None" = None,
243
+ drain: "Mapping | None" = None,
244
+ config: "Mapping | None" = None,
192
245
  ) -> "dict":
193
246
  """The contract body for one pass, from the console's builders.
194
247
 
@@ -199,7 +252,15 @@ def build_snapshot(
199
252
  the `sysinfo.memory_usage` reading, taken by the caller so a test can
200
253
  hand in a fixture. Pure assembly over the sources: the only IO is what
201
254
  `Sources` does (state.db, `tmux list-panes` + `/proc` per live session,
202
- the cached rate and PyPI reads)."""
255
+ the cached rate and PyPI reads).
256
+
257
+ `queued` (the undispatched alissa queue items, None when unread) and
258
+ `drain` (the holding drain row, or None) feed the `idle` block, whose
259
+ owed / timers come from the latest poll snapshot and whose live /
260
+ managed come from `roster` -- the same reading as `sessions`.
261
+
262
+ `config` is the seat's `config_block`, or None to leave the key out
263
+ (the console's push, which has no derived answer to give)."""
203
264
  parse = parse_session or _session_grammar()
204
265
  now_ts = heartbeat_at if now is None else now
205
266
 
@@ -246,8 +307,14 @@ def build_snapshot(
246
307
  + int(latest.get("revert_candidates") or 0)
247
308
  )
248
309
 
310
+ roster_live, roster_managed = parking.roster_counts(roster)
311
+ idle = parking.idle_block(
312
+ latest, live=roster_live, managed=roster_managed, queued=queued,
313
+ drain=drain, now=now_ts,
314
+ )
315
+
249
316
  drift = sources.drift()
250
- return {
317
+ snapshot = {
251
318
  "schemaVersion": SCHEMA_VERSION,
252
319
  "seat": SEAT,
253
320
  "asOf": iso(now_ts),
@@ -263,7 +330,67 @@ def build_snapshot(
263
330
  "queueDepth": queue_depth,
264
331
  "kpis": None,
265
332
  "inbox": inbox,
333
+ "idle": idle,
266
334
  }
335
+ if config is not None:
336
+ snapshot["config"] = dict(config)
337
+ return snapshot
338
+
339
+
340
+ def _carries_secret(value: str, secrets: "Sequence[str]") -> bool:
341
+ return any(secret in value for secret in secrets)
342
+
343
+
344
+ def config_block(
345
+ config: Config,
346
+ *,
347
+ gh_login: "str | None",
348
+ environ: "Mapping[str, str] | None" = None,
349
+ ) -> "dict":
350
+ """The seat's own word on what it derived (issue #155): four fields,
351
+ each from the config the daemon is RUNNING on, never from the raw env.
352
+
353
+ * `feedOwnerActorId` -- the bodies-of-work authority under
354
+ `repos_source: bows` (the token's own actor unless `bow_owners` names
355
+ one). Null in static mode (no feed is read) and when several owners
356
+ are trusted (there is no single answer to quote).
357
+ * `repos` -- the effective allowlist, sorted: under bows the derived
358
+ union the last refresh bound, in static mode the configured list.
359
+ * `reviewersRequested` -- the login(s) the handoff requests
360
+ (`ALISSA_DEV_REVIEWERS` and its fallbacks).
361
+ * `ghLogin` -- the account the gh token belongs to; null when unknown.
362
+
363
+ `environ` (default: the process env) supplies the secrets to scan for:
364
+ a value carrying one is blanked -- a list entry dropped, a scalar
365
+ nulled -- and named in a WARNING by field only."""
366
+ env = os.environ if environ is None else environ
367
+ secrets = [
368
+ v for k in SECRET_ENV if len(v := (env.get(k) or "").strip()) >= SECRET_MIN
369
+ ]
370
+ owners = config.bow_owners if config.repos_source == REPOS_BOWS else ()
371
+ block: "dict" = {
372
+ "feedOwnerActorId": owners[0] if len(owners) == 1 else None,
373
+ "repos": sorted(config.repos, key=str.casefold),
374
+ "reviewersRequested": list(config.reviewers),
375
+ "ghLogin": gh_login or None,
376
+ }
377
+ for key in CONFIG_KEYS:
378
+ value = block[key]
379
+ if isinstance(value, list):
380
+ kept = [v for v in value if not _carries_secret(v, secrets)]
381
+ blanked = len(kept) != len(value)
382
+ block[key] = kept
383
+ else:
384
+ blanked = value is not None and _carries_secret(value, secrets)
385
+ if blanked:
386
+ block[key] = None
387
+ if blanked:
388
+ log.warning(
389
+ "fleet vitals: the config block's %s carried a credential "
390
+ "this process holds -- blanked before sending; check the "
391
+ "variable it is derived from", key,
392
+ )
393
+ return block
267
394
 
268
395
 
269
396
  def encoded_size(snapshot: "Mapping") -> int:
@@ -312,6 +439,7 @@ class FleetVitalsPusher:
312
439
  sources: "Sources | None" = None,
313
440
  memory_reader: "Callable[[], Mapping | None]" = sysinfo.memory_usage,
314
441
  clock: "Callable[[], float]" = time.time,
442
+ config_reader: "Callable[[], Mapping | None] | None" = None,
315
443
  ):
316
444
  self.config = config
317
445
  self.client = client
@@ -320,7 +448,13 @@ class FleetVitalsPusher:
320
448
  )
321
449
  self._memory = memory_reader
322
450
  self._clock = clock
451
+ # The `config` block's source (the daemon's `_vitals_config`), or
452
+ # None -- the console's pusher -- to leave the block out.
453
+ self._config_reader = config_reader
323
454
  self._not_activated_warned = False
455
+ # Per optional block: until when it is left out (Studio refused it
456
+ # as an unknown key). Absent = offer it.
457
+ self._refused_until: "dict[str, float]" = {}
324
458
  self.last: "dict | None" = None
325
459
 
326
460
  def push_pass(self, roster: "Sequence[Mapping] | None") -> str:
@@ -328,10 +462,41 @@ class FleetVitalsPusher:
328
462
  FAILED (the caller quotes it in the poll summary). A snapshot that
329
463
  cannot be BUILT is a failure too — one WARNING, nothing sent."""
330
464
  heartbeat = self._clock()
465
+ return self._push(roster, heartbeat, heartbeat)
466
+
467
+ def push_now(self, roster: "Sequence[Mapping] | None") -> str:
468
+ """The console's out-of-pass push (the drain handshake, issue #153):
469
+ the same snapshot, its `heartbeatAt` the daemon's LAST completed pass
470
+ (the console completes none of its own -- stamping now would read as
471
+ a heartbeat the loop never gave), `asOf` now. PUSHED or FAILED."""
472
+ snaps = self.sources.snapshots(1)
473
+ now = self._clock()
474
+ heartbeat = snaps[0]["ts"] if snaps else now
475
+ return self._push(roster, heartbeat, now)
476
+
477
+ def _config(self) -> "Mapping | None":
478
+ """The `config` block, or None. A reader that fails costs the block,
479
+ never the snapshot: the rest of the vitals still go out."""
480
+ if self._config_reader is None:
481
+ return None
482
+ try:
483
+ return self._config_reader()
484
+ except Exception as exc:
485
+ log.warning(
486
+ "fleet vitals: could not derive the config block (%s) — "
487
+ "pushing the snapshot without it", exc,
488
+ )
489
+ return None
490
+
491
+ def _push(
492
+ self, roster: "Sequence[Mapping] | None", heartbeat: float, now: float,
493
+ ) -> str:
331
494
  try:
332
495
  snapshot = fit_to_body_cap(build_snapshot(
333
496
  self.sources, roster=roster, heartbeat_at=heartbeat,
334
- memory=self._memory(),
497
+ now=now, memory=self._memory(),
498
+ queued=self.sources.queued(), drain=self.sources.drain(),
499
+ config=self._config(),
335
500
  ))
336
501
  except Exception as exc:
337
502
  log.warning(
@@ -339,9 +504,26 @@ class FleetVitalsPusher:
339
504
  "pushed this pass; the next pass builds a fresh one", exc,
340
505
  )
341
506
  return FAILED
507
+ for key, until in self._refused_until.items():
508
+ if now < until:
509
+ snapshot.pop(key, None)
342
510
  self.last = snapshot
343
511
  return self._post(snapshot)
344
512
 
513
+ @staticmethod
514
+ def _refused_blocks(exc: AlissaError, snapshot: "Mapping") -> "list[str]":
515
+ """The optional blocks of `snapshot` a 400 names: a Studio whose
516
+ strict schema predates them (seat parking's lane L3 adds `idle`, the
517
+ provisioner's lane L5 adds `config`). A word match, so `config`
518
+ is not read into an unrelated message that merely contains it."""
519
+ if exc.status != 400:
520
+ return []
521
+ detail = str(exc.detail)
522
+ return [
523
+ key for key in OPTIONAL_BLOCKS
524
+ if key in snapshot and re.search(rf"\b{key}\b", detail)
525
+ ]
526
+
345
527
  def _post(self, snapshot: "dict") -> str:
346
528
  try:
347
529
  self.client.post_fleet_vitals(snapshot)
@@ -352,6 +534,21 @@ class FleetVitalsPusher:
352
534
  self._warn_failed(exc)
353
535
  return FAILED
354
536
  except AlissaError as exc:
537
+ refused = self._refused_blocks(exc, snapshot)
538
+ if refused:
539
+ until = self._clock() + BLOCK_REPROBE_S
540
+ for key in refused:
541
+ self._refused_until[key] = until
542
+ log.warning(
543
+ "fleet vitals: Studio refused the %s block(s) (status "
544
+ "%s: %s) — its schema predates them; resending without "
545
+ "them, and leaving them out for %d min",
546
+ ", ".join(f"`{k}`" for k in refused), exc.status,
547
+ exc.detail, int(BLOCK_REPROBE_S // 60),
548
+ )
549
+ trimmed = {k: v for k, v in snapshot.items() if k not in refused}
550
+ self.last = trimmed
551
+ return self._post(trimmed)
355
552
  self._warn_failed(exc)
356
553
  return FAILED
357
554
  sessions = snapshot.get("sessions") or {}