* feat(activity): scan new and unresolved files by default, all files on request
Scan for metadata now skips, before reading any metadata, the files the
Library already links with a resolved link (one generated_images row with
exactly one pair link), found with one streamed query that keeps only the
scanned paths and compares paths with os.path.normcase on both sides.
Skipped files count as Already known ("Skipped · already linked").
Unlinked, fallback-linked, ambiguous and unresolved files are read again.
POST /prompt_manager/jobs/scan takes {scope: "new" | "all"} (default
"new", anything else is a 400). The review dialog shows the concept's
"New and unresolved files" and adds an "All files" switch (D58). The
legacy /prompt_manager/scan route keeps scanning all files with unchanged
SSE payloads.
* feat(activity): download a job report
GET /prompt_manager/jobs/{id}/report returns the summary, counters, every
outcome group's listed rows with truncated flags, the scope and the log as a
JSON attachment (prompt-manager-<kind>-<YYYYMMDD-HHMM>.json), with no absolute
paths. The job view's Download report saves it through the api-client URL, and
the scan log opens at its newest line and follows new lines only while the
reader is at the bottom.
* fix(activity): re-read known files whose size changed, and review follow-ups
The new scope now skips a known, resolved file only while its stored
generated_images.file_size equals its size on disk; a NULL stored size is
re-read. A file replaced at the same path is read again and its conflict
shows under Needs review. The scan stores each file's size when it ingests
it, and both sides of the known-set match go through normalize_image_path.
A failed known-set read logs and falls back to reading every file, and an
empty request body asks for the default scope. The All files switch is
described by its hint, a lost job's Download report is disabled, the report
toast is skipped once the view is gone, and the scan log opens at its
newest line every time. Fixtures use the real scope shape.
* test(activity): wait for the job view's settled detail read, not a fixed delay
The 200-row cap test now starts counting detail reads once one read has
returned the full listing and none is in flight. The job view re-reads after
an in-flight read only when the summary still lists more than that read
returned, so no redundant read is left due after opening.
360 lines
13 KiB
Python
360 lines
13 KiB
Python
"""The session job registry: every long operation as one tracked job.
|
|
|
|
A job runs as an asyncio task that outlives the HTTP request that started it,
|
|
so an accidental tab close never abandons half-finished work. Each job keeps
|
|
its counters, a capped list of file outcomes per outcome group and a capped
|
|
log, and fans its progress out to SSE subscribers: the request that started
|
|
it, a reconnecting page, a second tab. A subscriber receives the latest event
|
|
as a snapshot and then every event published after it, until the job ends.
|
|
|
|
Jobs live only in server memory and are gone after a restart. The registry
|
|
keeps the running jobs and this session's last ``MAX_FINISHED_JOBS`` finished
|
|
ones. Record-changing jobs (the metadata scan, and later maintenance, cleanup
|
|
and restore) never run together, and one job of a kind runs at a time.
|
|
"""
|
|
|
|
import asyncio
|
|
import itertools
|
|
import logging
|
|
import re
|
|
import time
|
|
import uuid
|
|
from pathlib import PurePosixPath, PureWindowsPath
|
|
|
|
logger = logging.getLogger(__name__)
|
|
# Start and finish order: wall-clock times can tie (coarse Windows clocks).
|
|
_sequence = itertools.count()
|
|
|
|
JOB_STATES = ("running", "finished", "cancelled", "failed")
|
|
FINAL_STATES = JOB_STATES[1:]
|
|
MAX_OUTCOME_ROWS = 200 # rows listed per outcome group; counters stay exact
|
|
MAX_LOG_LINES = 2000
|
|
LOG_CAP_LINE = "[Log capped at 2,000 lines]"
|
|
MAX_FINISHED_JOBS = 20 # finished jobs this session keeps
|
|
|
|
# The metadata scan's outcome groups, in counter-tile order of the spec's
|
|
# mapping, and the per-file scan status each group collects. Files without
|
|
# usable metadata have no status and go to "no_metadata".
|
|
# Which files a scan reads (#249 Activity-2): "new" skips the files the Library
|
|
# already links with a resolved link (get_known_resolved_paths) before reading
|
|
# them, so new, unlinked, unresolved and ambiguous files are read; "all" reads
|
|
# every file again. The workspace job defaults to "new"; legacy /scan is "all".
|
|
SCAN_SCOPES = ("new", "all")
|
|
DEFAULT_SCAN_SCOPE = "new"
|
|
SCAN_GROUP_NAMES = ("added", "known", "review", "no_metadata", "errors")
|
|
SCAN_GROUPS = {
|
|
"added": "added",
|
|
"linked": "added",
|
|
"enriched": "added",
|
|
"unchanged": "known",
|
|
"skipped": "known", # "new" scope: known and resolved, not read again
|
|
"ambiguous": "review",
|
|
"conflict": "review",
|
|
"unresolved": "review",
|
|
"failed": "errors",
|
|
}
|
|
|
|
|
|
def _relative_path(path) -> str:
|
|
"""``path`` as a root-relative POSIX path; an absolute one keeps its name."""
|
|
text = str(path or "")
|
|
if (
|
|
PurePosixPath(text).is_absolute()
|
|
or PureWindowsPath(text).is_absolute()
|
|
or PureWindowsPath(text).drive
|
|
):
|
|
return re.split(r"[\\/]", text)[-1]
|
|
return text.replace("\\", "/")
|
|
|
|
|
|
class JobBusy(Exception):
|
|
"""A job could not start because ``running`` holds its slot."""
|
|
|
|
def __init__(self, running):
|
|
super().__init__(f"{running.title} is already running")
|
|
self.running = running
|
|
|
|
|
|
class Job:
|
|
"""One tracked operation: state, counters, outcomes, log and subscribers."""
|
|
|
|
def __init__(
|
|
self, kind, title, *, record_changing=False, scope=None, groups=(), clock=None
|
|
):
|
|
self._clock = clock or time.time
|
|
self.id = uuid.uuid4().hex
|
|
self.kind = kind
|
|
self.title = title
|
|
self.record_changing = record_changing
|
|
self.scope = dict(scope or {})
|
|
self.state = "running"
|
|
self.started_at = self._clock()
|
|
self._started_seq = next(_sequence)
|
|
self._finished_seq = None
|
|
self.finished_at = None
|
|
self.progress = 0
|
|
self.status_line = ""
|
|
self.processed = 0
|
|
self.total = 0
|
|
self.counters = dict.fromkeys(groups, 0)
|
|
self.outcomes = {group: [] for group in groups}
|
|
self.log = []
|
|
self.cancelled = False
|
|
self.last_event = None
|
|
self.task = None
|
|
self._subscribers = set()
|
|
|
|
@property
|
|
def running(self):
|
|
return self.state == "running"
|
|
|
|
@property
|
|
def finished(self):
|
|
return not self.running
|
|
|
|
@property
|
|
def subscriber_count(self):
|
|
return len(self._subscribers)
|
|
|
|
# ── progress ──────────────────────────────────────────────────────
|
|
|
|
def update(self, *, progress=None, status_line=None, processed=None, total=None):
|
|
"""Set the summary's progress fields; ``None`` leaves one unchanged."""
|
|
if progress is not None:
|
|
self.progress = max(0, min(100, int(progress)))
|
|
if status_line is not None:
|
|
self.status_line = status_line
|
|
if processed is not None:
|
|
self.processed = processed
|
|
if total is not None:
|
|
self.total = total
|
|
|
|
def publish(self, payload):
|
|
"""Record ``payload`` as the latest event and hand it to every subscriber."""
|
|
if isinstance(payload.get("progress"), (int, float)):
|
|
self.update(progress=payload["progress"])
|
|
self.last_event = payload
|
|
for queue in list(self._subscribers):
|
|
queue.put_nowait(payload)
|
|
|
|
def log_line(self, text):
|
|
"""Append a timestamped log line, up to MAX_LOG_LINES and one cap line."""
|
|
if len(self.log) > MAX_LOG_LINES:
|
|
return
|
|
if len(self.log) == MAX_LOG_LINES:
|
|
self.log.append(LOG_CAP_LINE)
|
|
return
|
|
stamp = time.strftime("%H:%M:%S", time.localtime(self._clock()))
|
|
self.log.append(f"{stamp} {text}")
|
|
|
|
def record(self, group, row, log_line):
|
|
"""Count one file outcome in ``group``, list its row and log it.
|
|
|
|
``row`` is ``{path, root, root_index, detail}``: the path relative to
|
|
its output root, the root's public label and its listing index (the
|
|
identity Images opens it by; None when unknown). An absolute path is
|
|
cut to its file name, so a row never reveals the server's layout.
|
|
"""
|
|
if group not in self.counters:
|
|
raise ValueError(f"Unknown outcome group {group!r} for {self.kind}")
|
|
self.counters[group] += 1
|
|
raw = str(row.get("path") or "")
|
|
path = _relative_path(raw)
|
|
if raw and raw != path:
|
|
log_line = log_line.replace(raw, path)
|
|
rows = self.outcomes[group]
|
|
index = row.get("root_index")
|
|
if isinstance(index, bool) or not isinstance(index, int) or index < 0:
|
|
index = None
|
|
if len(rows) < MAX_OUTCOME_ROWS:
|
|
rows.append(
|
|
{
|
|
"path": path,
|
|
"root": str(row.get("root") or ""),
|
|
"root_index": index,
|
|
"detail": str(row.get("detail") or ""),
|
|
}
|
|
)
|
|
self.log_line(log_line)
|
|
|
|
def finish(self, state="finished"):
|
|
"""End the job in ``state`` and release every subscriber.
|
|
|
|
Each subscriber still gets the events queued before the release. A
|
|
second finish is a no-op, so the first final state stands.
|
|
"""
|
|
if state not in FINAL_STATES:
|
|
raise ValueError(f"{state!r} is not a final job state")
|
|
if self.finished:
|
|
return
|
|
self.state = state
|
|
self.finished_at = self._clock()
|
|
self._finished_seq = next(_sequence)
|
|
for queue in list(self._subscribers):
|
|
queue.put_nowait(None)
|
|
|
|
async def events(self):
|
|
"""Yield the latest event, then each later one, until the job finishes.
|
|
|
|
Consume it under ``contextlib.aclosing`` so a consumer that stops early
|
|
(a closed socket) drops its queue at once instead of at garbage collection.
|
|
"""
|
|
queue = asyncio.Queue()
|
|
self._subscribers.add(queue)
|
|
try:
|
|
if self.last_event is not None:
|
|
yield self.last_event
|
|
if self.finished:
|
|
return
|
|
while True:
|
|
payload = await queue.get()
|
|
if payload is None:
|
|
return
|
|
yield payload
|
|
finally:
|
|
self._subscribers.discard(queue)
|
|
|
|
# ── views ─────────────────────────────────────────────────────────
|
|
|
|
def summary(self):
|
|
"""The job as the Activity list and pill show it."""
|
|
return {
|
|
"id": self.id,
|
|
"kind": self.kind,
|
|
"title": self.title,
|
|
"state": self.state,
|
|
"started_at": self.started_at,
|
|
"finished_at": self.finished_at,
|
|
"progress": self.progress,
|
|
"status_line": self.status_line,
|
|
"processed": self.processed,
|
|
"total": self.total,
|
|
"counters": dict(self.counters),
|
|
"scope": dict(self.scope),
|
|
# What detail() lists, so a view re-reads it only when it changed.
|
|
"listed": {group: len(rows) for group, rows in self.outcomes.items()},
|
|
"log_length": len(self.log),
|
|
}
|
|
|
|
def detail(self):
|
|
"""The summary plus the listed outcome rows and the log."""
|
|
return {
|
|
**self.summary(),
|
|
"outcomes": {
|
|
group: [dict(row) for row in rows]
|
|
for group, rows in self.outcomes.items()
|
|
},
|
|
"log": list(self.log),
|
|
}
|
|
|
|
def report(self):
|
|
"""The downloadable report: the summary, every outcome group and the log.
|
|
|
|
Each group carries its exact count, its listed rows (at most
|
|
MAX_OUTCOME_ROWS) and whether the list is cut short. Rows and log lines
|
|
already hold root-relative paths only.
|
|
"""
|
|
summary = self.summary()
|
|
summary.pop("listed", None)
|
|
summary.pop("log_length", None)
|
|
return {
|
|
"report_version": 1,
|
|
"generated_at": self._clock(),
|
|
"job": summary,
|
|
"outcomes": {
|
|
group: {
|
|
"count": self.counters.get(group, len(rows)),
|
|
"rows": [dict(row) for row in rows],
|
|
"truncated": self.counters.get(group, len(rows)) > len(rows),
|
|
}
|
|
for group, rows in self.outcomes.items()
|
|
},
|
|
"log": list(self.log),
|
|
}
|
|
|
|
def report_filename(self):
|
|
"""The download name: prompt-manager-KIND-YYYYMMDD-HHMM.json (local time)."""
|
|
stamp = time.strftime("%Y%m%d-%H%M", time.localtime(self._clock()))
|
|
return f"prompt-manager-{self.kind}-{stamp}.json"
|
|
|
|
|
|
class JobRegistry:
|
|
"""Every job of this server session, running and recently finished."""
|
|
|
|
def __init__(self, logger_=None):
|
|
self._logger = logger_ or logger
|
|
self._jobs = {}
|
|
|
|
def start(self, kind, title, *, record_changing, scope, runner, groups=()) -> Job:
|
|
"""Create a job and run ``runner(job)`` as a task.
|
|
|
|
Raises JobBusy, naming the job in the way, when a job of ``kind`` is
|
|
running or when ``record_changing`` and another record-changing job
|
|
runs. Must be called from the event loop.
|
|
"""
|
|
same_kind = self.active(kind)
|
|
if same_kind is not None:
|
|
raise JobBusy(same_kind)
|
|
if record_changing:
|
|
for job in self._jobs.values():
|
|
if job.running and job.record_changing:
|
|
raise JobBusy(job)
|
|
self._prune()
|
|
job = Job(
|
|
kind, title, record_changing=record_changing, scope=scope, groups=groups
|
|
)
|
|
self._jobs[job.id] = job
|
|
job.task = asyncio.ensure_future(self._run(job, runner))
|
|
return job
|
|
|
|
async def _run(self, job, runner):
|
|
try:
|
|
await runner(job)
|
|
except asyncio.CancelledError:
|
|
job.finish("cancelled")
|
|
raise
|
|
except Exception:
|
|
self._logger.exception(f"Job {job.kind} failed")
|
|
job.finish("failed")
|
|
finally:
|
|
if job.running:
|
|
job.finish("cancelled" if job.cancelled else "finished")
|
|
|
|
def _prune(self):
|
|
"""Drop the oldest finished jobs so a new one keeps the cap."""
|
|
finished = [job for job in self._jobs.values() if job.finished]
|
|
finished.sort(key=lambda job: job._finished_seq)
|
|
for job in finished[: max(0, len(finished) - MAX_FINISHED_JOBS + 1)]:
|
|
del self._jobs[job.id]
|
|
|
|
def get(self, job_id):
|
|
return self._jobs.get(job_id)
|
|
|
|
def active(self, kind):
|
|
"""The running job of ``kind``, or None."""
|
|
for job in self._jobs.values():
|
|
if job.kind == kind and job.running:
|
|
return job
|
|
return None
|
|
|
|
def list(self):
|
|
"""Running jobs first, then the last finished ones, newest first."""
|
|
jobs = list(self._jobs.values())
|
|
running = [job for job in jobs if job.running]
|
|
finished = [job for job in jobs if job.finished]
|
|
running.sort(key=lambda job: job._started_seq, reverse=True)
|
|
finished.sort(key=lambda job: job._finished_seq, reverse=True)
|
|
return running + finished[:MAX_FINISHED_JOBS]
|
|
|
|
def cancel(self, job_id) -> bool:
|
|
"""Ask a running job to stop at its next safe boundary.
|
|
|
|
Returns whether a cancellation was requested: False for a finished or
|
|
unknown job, so a cancel after the end is a no-op.
|
|
"""
|
|
job = self._jobs.get(job_id)
|
|
if job is None or job.finished:
|
|
return False
|
|
job.cancelled = True
|
|
return True
|