alissa-tools-github-devloop 0.8.36__py3-none-any.whl → 0.8.38__py3-none-any.whl
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.
- alissa/tools/github/devloop/fleet_vitals.py +201 -4
- alissa/tools/github/devloop/loop.py +288 -9
- alissa/tools/github/devloop/loop_events.py +1 -0
- alissa/tools/github/devloop/parking.py +205 -0
- alissa/tools/github/devloop/state.py +133 -0
- alissa/tools/github/devloop/version +1 -1
- alissa/tools/github/devloop/webui/__main__.py +24 -1
- alissa/tools/github/devloop/webui/server.py +124 -2
- alissa/tools/github/devloop/webui/sources.py +51 -1
- {alissa_tools_github_devloop-0.8.36.dist-info → alissa_tools_github_devloop-0.8.38.dist-info}/METADATA +1 -1
- {alissa_tools_github_devloop-0.8.36.dist-info → alissa_tools_github_devloop-0.8.38.dist-info}/RECORD +16 -15
- {alissa_tools_github_devloop-0.8.36.dist-info → alissa_tools_github_devloop-0.8.38.dist-info}/WHEEL +0 -0
- {alissa_tools_github_devloop-0.8.36.dist-info → alissa_tools_github_devloop-0.8.38.dist-info}/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.36.dist-info → alissa_tools_github_devloop-0.8.38.dist-info}/licenses/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.36.dist-info → alissa_tools_github_devloop-0.8.38.dist-info}/licenses/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.36.dist-info → alissa_tools_github_devloop-0.8.38.dist-info}/top_level.txt +0 -0
|
@@ -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
|
-
|
|
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 {}
|