alissa-tools-github-devloop 0.8.12__tar.gz → 0.8.14__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.
- {alissa_tools_github_devloop-0.8.12/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.14}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/__main__.py +28 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/alissa_client.py +45 -9
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/config.py +91 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/janitor.py +79 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/loop.py +480 -35
- alissa_tools_github_devloop-0.8.14/src/main/alissa/tools/github/devloop/loop_events.py +532 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/state.py +116 -0
- alissa_tools_github_devloop-0.8.14/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/page.py +23 -2
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/sources.py +36 -3
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
- alissa_tools_github_devloop-0.8.12/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/bows.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.12 → alissa_tools_github_devloop-0.8.14}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -282,6 +282,26 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
282
282
|
"(two overlapping instances can then both spawn on one item)",
|
|
283
283
|
)
|
|
284
284
|
|
|
285
|
+
loop_events = over.add_mutually_exclusive_group()
|
|
286
|
+
loop_events.add_argument(
|
|
287
|
+
"--loop-events",
|
|
288
|
+
dest="loop_events_enabled",
|
|
289
|
+
action="store_true",
|
|
290
|
+
default=None,
|
|
291
|
+
help="push one idempotent batch of loop events (spawns, defers, "
|
|
292
|
+
"cap-outs, stalls, hand-offs, reaps, escalations) per poll pass to "
|
|
293
|
+
"Studio's POST /v1/loop-events on the alissa endpoint — best-effort, "
|
|
294
|
+
"never fatal. ALISSA_DEV_LOOP_EVENTS_ENABLED (non-blank) overrides "
|
|
295
|
+
"even this flag",
|
|
296
|
+
)
|
|
297
|
+
loop_events.add_argument(
|
|
298
|
+
"--no-loop-events",
|
|
299
|
+
dest="loop_events_enabled",
|
|
300
|
+
action="store_false",
|
|
301
|
+
help="never push loop telemetry, even if the config enables it "
|
|
302
|
+
"(the default: the push is opt-in)",
|
|
303
|
+
)
|
|
304
|
+
|
|
285
305
|
janitor = over.add_mutually_exclusive_group()
|
|
286
306
|
janitor.add_argument(
|
|
287
307
|
"--janitor",
|
|
@@ -386,6 +406,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
386
406
|
"orphan_sweep_enabled": args.orphan_sweep_enabled,
|
|
387
407
|
"orphan_grace_minutes": args.orphan_grace_minutes,
|
|
388
408
|
"activity_claims_enabled": args.activity_claims_enabled,
|
|
409
|
+
"loop_events_enabled": args.loop_events_enabled,
|
|
389
410
|
"janitor_enabled": args.janitor_enabled,
|
|
390
411
|
"janitor_grace_minutes": args.janitor_grace_minutes,
|
|
391
412
|
"janitor_interval_minutes": args.janitor_interval_minutes,
|
|
@@ -500,6 +521,13 @@ def log_effective_config(config: Config, login: str) -> None:
|
|
|
500
521
|
config.cache_cap_mb,
|
|
501
522
|
config.disk_warn_percent,
|
|
502
523
|
)
|
|
524
|
+
log.info(
|
|
525
|
+
"loop_events=%s%s",
|
|
526
|
+
config.loop_events_enabled,
|
|
527
|
+
f" (one batch per pass → POST {config.alissa_endpoint}"
|
|
528
|
+
f"/v1/loop-events, best-effort)"
|
|
529
|
+
if config.loop_events_enabled else "",
|
|
530
|
+
)
|
|
503
531
|
log.debug("hub_template: %s", config.hub_template)
|
|
504
532
|
log.debug(
|
|
505
533
|
"cache_paths: %s",
|
|
@@ -22,7 +22,12 @@ already authenticates — the same transport, endpoints and taxonomy orcloop's
|
|
|
22
22
|
`urllib` only: the distribution ships no third-party runtime dependency, and
|
|
23
23
|
this must not be the change that adds one.
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
Since issue #101 the adapter also carries the daemon's ONE write —
|
|
26
|
+
``POST /v1/loop-events`` (`post_loop_events`), the loop-telemetry push — over
|
|
27
|
+
the same transport, token and taxonomy, so the daemon still reads and writes
|
|
28
|
+
Alissa through a single REST idiom.
|
|
29
|
+
|
|
30
|
+
Endpoints, the reads both verified against the live API:
|
|
26
31
|
|
|
27
32
|
``GET /v1/ping``
|
|
28
33
|
``{"pong", "timestamp", "userId", "actorId", "displayName"}`` — the
|
|
@@ -141,22 +146,32 @@ class AlissaClient:
|
|
|
141
146
|
self._token = token if token is not None else os.environ.get(ENV_TOKEN)
|
|
142
147
|
self._timeout = timeout
|
|
143
148
|
|
|
144
|
-
def _request(
|
|
145
|
-
|
|
146
|
-
|
|
149
|
+
def _request(
|
|
150
|
+
self, path: str, *, method: str = "GET", body: object = None
|
|
151
|
+
) -> object:
|
|
152
|
+
"""One call. Every failure leaves as a taxonomy exception — the caller
|
|
153
|
+
never sees a raw urllib error or an HTTP status. `body`, when given,
|
|
154
|
+
is JSON-serialised and sent with the matching Content-Type (the shape
|
|
155
|
+
every write on this API takes)."""
|
|
147
156
|
if not self._token:
|
|
148
157
|
# No token at all is an auth condition, not a transport one: the
|
|
149
158
|
# operator must set the env var. Fail the way a 401 would, so the
|
|
150
159
|
# boot path treats it as permanent rather than retrying forever.
|
|
151
160
|
raise AlissaAuthError(0, f"{ENV_TOKEN} is not set")
|
|
152
161
|
|
|
162
|
+
headers = {
|
|
163
|
+
"Accept": "application/json",
|
|
164
|
+
"Authorization": f"Bearer {self._token}",
|
|
165
|
+
}
|
|
166
|
+
data = None
|
|
167
|
+
if body is not None:
|
|
168
|
+
data = json.dumps(body).encode("utf-8")
|
|
169
|
+
headers["Content-Type"] = "application/json"
|
|
153
170
|
req = urllib.request.Request(
|
|
154
171
|
f"{self.base}{path}",
|
|
155
|
-
method=
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
"Authorization": f"Bearer {self._token}",
|
|
159
|
-
},
|
|
172
|
+
method=method,
|
|
173
|
+
data=data,
|
|
174
|
+
headers=headers,
|
|
160
175
|
)
|
|
161
176
|
try:
|
|
162
177
|
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
@@ -226,6 +241,27 @@ class AlissaClient:
|
|
|
226
241
|
display_name=str(row.get("displayName") or ""),
|
|
227
242
|
)
|
|
228
243
|
|
|
244
|
+
def post_loop_events(self, events: "list[dict]") -> "dict":
|
|
245
|
+
"""Push one batch of loop-telemetry events (issue #101).
|
|
246
|
+
|
|
247
|
+
``POST /v1/loop-events`` (fahera-mx/studio.alissa.app, `API.md` →
|
|
248
|
+
"Loop telemetry — ingest"): 1–200 events per call, idempotent on the
|
|
249
|
+
caller's `dedupeKey` — a repeated key is a silent duplicate, never an
|
|
250
|
+
error — and any shape error fails the WHOLE batch with a 400 naming
|
|
251
|
+
the offending index. The batch bound and the event shapes are the
|
|
252
|
+
CALLER's contract (`loop_events.py` derives and splits); this method
|
|
253
|
+
is just the transport, returning the server's
|
|
254
|
+
``{"accepted": n, "duplicates": m}`` verbatim.
|
|
255
|
+
|
|
256
|
+
Failures leave as the taxonomy like every other call here. The one
|
|
257
|
+
caller is best-effort by design — it catches AlissaError, warns once
|
|
258
|
+
and moves on — so nothing extra is softened at this layer.
|
|
259
|
+
"""
|
|
260
|
+
payload = self._request(
|
|
261
|
+
"/v1/loop-events", method="POST", body={"events": list(events)}
|
|
262
|
+
)
|
|
263
|
+
return payload if isinstance(payload, dict) else {}
|
|
264
|
+
|
|
229
265
|
def list_bodies_of_work(self) -> "list[BodyOfWork]":
|
|
230
266
|
"""Every Body of Work this actor owns OR collaborates on — ONE call.
|
|
231
267
|
|
|
@@ -21,6 +21,8 @@ var > `--repos-source` > the config key. A BLANK env var means "not set" here
|
|
|
21
21
|
(unlike ALISSA_DEV_REVIEWERS, where empty is a meaningful answer): an unset
|
|
22
22
|
platform variable reference renders as the empty string, and that must fall
|
|
23
23
|
through to the config rather than fail the daemon's boot on a mode of `''`.
|
|
24
|
+
`loop_events_enabled` rides the same shape of rail through
|
|
25
|
+
`ALISSA_DEV_LOOP_EVENTS_ENABLED`, with the same blank-falls-through rule.
|
|
24
26
|
"""
|
|
25
27
|
|
|
26
28
|
from __future__ import annotations
|
|
@@ -64,6 +66,17 @@ FOREIGN_IGNORE = "ignore" # today's behavior: never even considered
|
|
|
64
66
|
|
|
65
67
|
_FOREIGN_POLICIES = {FOREIGN_REAP, FOREIGN_WARN, FOREIGN_IGNORE}
|
|
66
68
|
|
|
69
|
+
# What the workspace janitor does about a dirty HOLDOUT -- a task workspace
|
|
70
|
+
# whose anchor PR is terminal but which carries uncommitted or unpushed work
|
|
71
|
+
# (issue #99). `warn` is the invariant as it has always been read: never
|
|
72
|
+
# delete unpushed work, say so once per episode. `archive` keeps the invariant
|
|
73
|
+
# and adds a way out: the work is committed, pushed to a NEW
|
|
74
|
+
# `refs/heads/holdout/<branch>` ref, verified there, and only then reclaimed.
|
|
75
|
+
HOLDOUT_WARN = "warn" # track and warn (once per 24 h); never touch the tree
|
|
76
|
+
HOLDOUT_ARCHIVE = "archive" # commit, push to holdout/<branch>, verify, reclaim
|
|
77
|
+
|
|
78
|
+
_HOLDOUT_POLICIES = {HOLDOUT_WARN, HOLDOUT_ARCHIVE}
|
|
79
|
+
|
|
67
80
|
CONFIG_FILENAME = "devloop.config.json"
|
|
68
81
|
|
|
69
82
|
# Comma-separated reviewer logins; authoritative when set, even if empty.
|
|
@@ -77,6 +90,18 @@ ENV_REPOS_SOURCE = "ALISSA_DEV_REPOS_SOURCE"
|
|
|
77
90
|
# `alissa_endpoint` so one deployment can point both daemons at one API.
|
|
78
91
|
DEFAULT_ALISSA_ENDPOINT = "https://api.alissa.app"
|
|
79
92
|
|
|
93
|
+
# The loop-telemetry rail (issue #101): `loop_events_enabled` ridden the way
|
|
94
|
+
# ALISSA_DEV_REPOS_SOURCE rides repos_source — a NON-BLANK value here wins
|
|
95
|
+
# over the CLI and the file alike, and a blank one falls through (an unset
|
|
96
|
+
# platform variable reference renders as "", which must not decide anything).
|
|
97
|
+
# Accepted spellings are 1/true/0/false, case-insensitive; anything else is a
|
|
98
|
+
# config error at boot — a typo silently read as false would leave an operator
|
|
99
|
+
# staring at a variable that does nothing.
|
|
100
|
+
ENV_LOOP_EVENTS = "ALISSA_DEV_LOOP_EVENTS_ENABLED"
|
|
101
|
+
|
|
102
|
+
_ENV_BOOL_TRUE = frozenset(("1", "true"))
|
|
103
|
+
_ENV_BOOL_FALSE = frozenset(("0", "false"))
|
|
104
|
+
|
|
80
105
|
# What an Alissa actor id looks like: a 32-character opaque handle of
|
|
81
106
|
# lower-case letters and digits (`j5706fv7xe5jy1k5wdwzacab9s8axcd2`). Every id
|
|
82
107
|
# the API mints has this shape, and `bow_owners` is checked against it so a
|
|
@@ -127,9 +152,11 @@ CONFIG_KEYS = (
|
|
|
127
152
|
"orphan_sweep_enabled",
|
|
128
153
|
"orphan_grace_minutes",
|
|
129
154
|
"activity_claims_enabled",
|
|
155
|
+
"loop_events_enabled",
|
|
130
156
|
"janitor_enabled",
|
|
131
157
|
"janitor_grace_minutes",
|
|
132
158
|
"janitor_interval_minutes",
|
|
159
|
+
"janitor_holdout_policy",
|
|
133
160
|
"cache_cap_mb",
|
|
134
161
|
"cache_paths",
|
|
135
162
|
"disk_warn_percent",
|
|
@@ -487,6 +514,18 @@ class Config:
|
|
|
487
514
|
# that would rather not pay the extra comments fetch.
|
|
488
515
|
activity_claims_enabled: bool = True
|
|
489
516
|
|
|
517
|
+
# The loop-telemetry push (issue #101): at the end of each poll pass,
|
|
518
|
+
# POST one idempotent batch of the ledger-derived loop events — spawns,
|
|
519
|
+
# defers, cap-outs, stalls, hand-offs, reaps, escalations — to Studio's
|
|
520
|
+
# /v1/loop-events on `alissa_endpoint`, over the same REST client and
|
|
521
|
+
# token `bows` mode reads with. Best-effort and never fatal: a failed
|
|
522
|
+
# push warns once and the pass completes, and deterministic dedupe keys
|
|
523
|
+
# make re-emission harmless. Default OFF — the push writes to an external
|
|
524
|
+
# service on every pass, so it is the operator's to opt into; disabled,
|
|
525
|
+
# no request is ever made. ALISSA_DEV_LOOP_EVENTS_ENABLED (non-blank)
|
|
526
|
+
# wins over the CLI and the file, repos_source's rail exactly.
|
|
527
|
+
loop_events_enabled: bool = False
|
|
528
|
+
|
|
490
529
|
# The workspace janitor (issue #74): the filesystem sibling of the session
|
|
491
530
|
# reaper. Every worker session materializes a per-task workspace under its
|
|
492
531
|
# repo's hub (a `TASK-<n>-<DESC>` worktree plus whatever its lane installs
|
|
@@ -518,6 +557,21 @@ class Config:
|
|
|
518
557
|
# so the telemetry line never goes quiet. 0 means every poll.
|
|
519
558
|
janitor_interval_minutes: int = 10
|
|
520
559
|
|
|
560
|
+
# What the janitor does about a dirty holdout (issue #99). Default `warn`:
|
|
561
|
+
# the workspace is tracked in the `janitor_holdouts` ledger table, warned
|
|
562
|
+
# about on first sighting and at most once per 24 h after that (DEBUG in
|
|
563
|
+
# between), and never touched -- the "never delete unpushed work"
|
|
564
|
+
# invariant exactly as before, minus the ~3,000 identical WARNING lines a
|
|
565
|
+
# day the fleet measured. `archive` is opt-in: a holdout whose anchor is
|
|
566
|
+
# terminal and whose branch is a task branch has its work committed (when
|
|
567
|
+
# dirty), pushed to a NEW `refs/heads/holdout/<branch>` ref (never the
|
|
568
|
+
# task branch, never force), verified there by `ls-remote`, and only then
|
|
569
|
+
# reclaimed like a clean workspace. Any failure leaves the workspace in
|
|
570
|
+
# place -- at worst with its work committed locally, never removed -- and
|
|
571
|
+
# pages `janitor-archive-failed` once per workspace. The commit stages
|
|
572
|
+
# EVERY untracked, non-ignored file, scratch directories included.
|
|
573
|
+
janitor_holdout_policy: str = HOLDOUT_WARN
|
|
574
|
+
|
|
521
575
|
# The cache bound, in MiB: package-manager caches over this are trimmed
|
|
522
576
|
# oldest-entry-first back under it. 0 disables the trim entirely. A cache
|
|
523
577
|
# is a legitimate speed tradeoff -- the default is generous on purpose
|
|
@@ -732,6 +786,28 @@ class Config:
|
|
|
732
786
|
+ (f" (from {ENV_REPOS_SOURCE})" if env_source else "")
|
|
733
787
|
)
|
|
734
788
|
|
|
789
|
+
# The loop-telemetry rail, repos_source's shape exactly: a NON-BLANK
|
|
790
|
+
# ALISSA_DEV_LOOP_EVENTS_ENABLED wins over the CLI and the file, and
|
|
791
|
+
# blank falls through. Validated by NAME like the enums — a typo read
|
|
792
|
+
# as false would silently disable a push the operator asked for.
|
|
793
|
+
loop_events = bool(
|
|
794
|
+
raw.get("loop_events_enabled", cls.loop_events_enabled)
|
|
795
|
+
)
|
|
796
|
+
env_loop_events = (os.environ if environ is None else environ).get(
|
|
797
|
+
ENV_LOOP_EVENTS, ""
|
|
798
|
+
).strip()
|
|
799
|
+
if env_loop_events:
|
|
800
|
+
lowered = env_loop_events.casefold()
|
|
801
|
+
if lowered in _ENV_BOOL_TRUE:
|
|
802
|
+
loop_events = True
|
|
803
|
+
elif lowered in _ENV_BOOL_FALSE:
|
|
804
|
+
loop_events = False
|
|
805
|
+
else:
|
|
806
|
+
raise ValueError(
|
|
807
|
+
f"{ENV_LOOP_EVENTS} must be one of 1/true/0/false "
|
|
808
|
+
f"(case-insensitive), got {env_loop_events!r}"
|
|
809
|
+
)
|
|
810
|
+
|
|
735
811
|
bow_owners = normalize_bow_owners(raw.get("bow_owners", cls.bow_owners))
|
|
736
812
|
|
|
737
813
|
refresh_polls = int(
|
|
@@ -853,6 +929,19 @@ class Config:
|
|
|
853
929
|
f"got {janitor_interval}"
|
|
854
930
|
)
|
|
855
931
|
|
|
932
|
+
# The holdout policy. An enum validated by NAME like
|
|
933
|
+
# foreign_session_policy: a typo would otherwise be "not archive",
|
|
934
|
+
# i.e. silently the default, for an operator who set the key to opt
|
|
935
|
+
# INTO a git push -- the one outcome they cannot have wanted.
|
|
936
|
+
holdout_policy = str(
|
|
937
|
+
raw.get("janitor_holdout_policy", cls.janitor_holdout_policy)
|
|
938
|
+
).strip()
|
|
939
|
+
if holdout_policy not in _HOLDOUT_POLICIES:
|
|
940
|
+
raise ValueError(
|
|
941
|
+
f"janitor_holdout_policy must be one of "
|
|
942
|
+
f"{sorted(_HOLDOUT_POLICIES)}, got {holdout_policy!r}"
|
|
943
|
+
)
|
|
944
|
+
|
|
856
945
|
cache_cap = int(raw.get("cache_cap_mb", cls.cache_cap_mb))
|
|
857
946
|
if cache_cap < 0:
|
|
858
947
|
raise ValueError(
|
|
@@ -921,9 +1010,11 @@ class Config:
|
|
|
921
1010
|
activity_claims_enabled=bool(
|
|
922
1011
|
raw.get("activity_claims_enabled", cls.activity_claims_enabled)
|
|
923
1012
|
),
|
|
1013
|
+
loop_events_enabled=loop_events,
|
|
924
1014
|
janitor_enabled=bool(raw.get("janitor_enabled", cls.janitor_enabled)),
|
|
925
1015
|
janitor_grace_minutes=janitor_grace,
|
|
926
1016
|
janitor_interval_minutes=janitor_interval,
|
|
1017
|
+
janitor_holdout_policy=holdout_policy,
|
|
927
1018
|
cache_cap_mb=cache_cap,
|
|
928
1019
|
cache_paths=_string_list(
|
|
929
1020
|
raw.get("cache_paths", cls.cache_paths), "cache_paths"
|
|
@@ -53,6 +53,18 @@ log = logging.getLogger(__name__)
|
|
|
53
53
|
# one of ours.
|
|
54
54
|
TASK_WORKSPACE_RE = re.compile(r"TASK-(?P<task>\d+)(?:-(?P<desc>[A-Za-z0-9._-]+))?")
|
|
55
55
|
|
|
56
|
+
# Where the `archive` holdout policy pushes unpushed work (issue #99): a NEW
|
|
57
|
+
# ref under this prefix, named after the task branch. Never the task branch
|
|
58
|
+
# itself -- its PR is closed, and a push there would resurrect a dead lane --
|
|
59
|
+
# and never `--force`, so a ref that already exists at a different sha refuses
|
|
60
|
+
# the push and the workspace stays. An operator recovers the work with
|
|
61
|
+
# `git fetch origin holdout/<branch>`.
|
|
62
|
+
HOLDOUT_REF_PREFIX = "refs/heads/holdout/"
|
|
63
|
+
|
|
64
|
+
# The remote the archive pushes to. The dev identity already pushes task
|
|
65
|
+
# branches to it; the archive needs no new permission.
|
|
66
|
+
HOLDOUT_REMOTE = "origin"
|
|
67
|
+
|
|
56
68
|
# Package-manager caches, as they land in this image and every other
|
|
57
69
|
# Debian-ish agent container. Used only when the operator configures no
|
|
58
70
|
# `cache_paths` of their own; entries that do not exist are skipped silently,
|
|
@@ -117,6 +129,21 @@ def parse_task_workspace(path: Path, hub: Path) -> "TaskWorkspace | None":
|
|
|
117
129
|
)
|
|
118
130
|
|
|
119
131
|
|
|
132
|
+
def is_task_branch(name: str) -> bool:
|
|
133
|
+
"""Is `name` one of devloop's own task branches (`TASK-<n>-<DESC>`)?
|
|
134
|
+
|
|
135
|
+
The same full-match grammar `parse_task_workspace` admits directories
|
|
136
|
+
with, re-asserted by name at the one place that pushes: the archive path
|
|
137
|
+
writes a ref to the remote, and its name is derived from this one, so a
|
|
138
|
+
branch the daemon did not create must never get a `holdout/` twin."""
|
|
139
|
+
return TASK_WORKSPACE_RE.fullmatch(name) is not None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def holdout_ref(branch: str) -> str:
|
|
143
|
+
"""The full ref the archive pushes `branch`'s work to."""
|
|
144
|
+
return f"{HOLDOUT_REF_PREFIX}{branch}"
|
|
145
|
+
|
|
146
|
+
|
|
120
147
|
def scan_hub(hub: Path) -> "list[TaskWorkspace]":
|
|
121
148
|
"""Every task workspace directly under `hub`, sorted by name.
|
|
122
149
|
|
|
@@ -375,6 +402,58 @@ def delete_branch(common: Path, branch: str, runner: "Runner" = run) -> None:
|
|
|
375
402
|
_git(["branch", "-D", branch], common, runner)
|
|
376
403
|
|
|
377
404
|
|
|
405
|
+
# -- the archive's git plumbing (issue #99) --------------------------------
|
|
406
|
+
#
|
|
407
|
+
# Three commands, each one step of the `archive` holdout policy, each doing
|
|
408
|
+
# exactly what its name says and nothing else: the ORDER (commit only when
|
|
409
|
+
# dirty, push to the holdout ref, verify by ls-remote, and only then remove)
|
|
410
|
+
# and every refusal live in `loop.DevWatcher._archive_holdout`. All three
|
|
411
|
+
# raise CommandError on failure, which the caller turns into "the workspace
|
|
412
|
+
# stays" -- nothing here decides.
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def commit_all(path: Path, message: str, runner: "Runner" = run) -> None:
|
|
416
|
+
"""`git add -A && git commit --allow-empty -m <message>` in `path`.
|
|
417
|
+
|
|
418
|
+
`-A` stages every uncommitted change `dirty_entries` reported, untracked
|
|
419
|
+
files included, because that is precisely the work the holdout exists to
|
|
420
|
+
keep; ignored build output stays ignored. That is BROADER than "the
|
|
421
|
+
work": a `.harness/` or `.fu…prep/` scratch directory a session left
|
|
422
|
+
behind is untracked and not ignored, so it goes into the commit and onto
|
|
423
|
+
the remote ref too. The caller logs the entry count before this runs and
|
|
424
|
+
the docs say so -- an operator opting into `archive` opts into that. `--allow-empty` so a status that
|
|
425
|
+
went clean between the check and the commit still yields a commit that
|
|
426
|
+
carries the message -- the archive's provenance line on the ref."""
|
|
427
|
+
_git(["add", "-A"], path, runner)
|
|
428
|
+
_git(["commit", "--allow-empty", "-m", message], path, runner)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def push_ref(path: Path, ref: str, runner: "Runner" = run) -> None:
|
|
432
|
+
"""`git push origin HEAD:<ref>` from `path`. Deliberately WITHOUT
|
|
433
|
+
`--force`, ever: a ref that already exists at another sha rejects the
|
|
434
|
+
push, and the caller keeps the workspace. `ref` is a full ref name
|
|
435
|
+
(`refs/heads/holdout/<branch>`), so git cannot resolve it to anything but
|
|
436
|
+
a branch under `holdout/`."""
|
|
437
|
+
_git(["push", HOLDOUT_REMOTE, f"HEAD:{ref}"], path, runner)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def remote_ref_sha(path: Path, ref: str, runner: "Runner" = run) -> "str | None":
|
|
441
|
+
"""The sha `ref` points at on the remote, per `git ls-remote origin
|
|
442
|
+
<ref>`, or None when the remote has no such ref.
|
|
443
|
+
|
|
444
|
+
THE verification step: the archive deletes only when this equals the
|
|
445
|
+
local HEAD. A remote that cannot be asked raises (the caller keeps the
|
|
446
|
+
workspace and says why); a remote that answers "no such ref" is the same
|
|
447
|
+
verdict spelled quietly, hence None rather than a raise -- the caller
|
|
448
|
+
compares it to a real sha and refuses."""
|
|
449
|
+
out = _git(["ls-remote", HOLDOUT_REMOTE, ref], path, runner)
|
|
450
|
+
for line in out.splitlines():
|
|
451
|
+
sha, _, name = line.strip().partition("\t")
|
|
452
|
+
if name == ref and sha:
|
|
453
|
+
return sha
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
|
|
378
457
|
def remove_tree(path: Path) -> None:
|
|
379
458
|
"""Delete a standalone checkout's directory outright.
|
|
380
459
|
|