From a686d35641559a5b03efe99772411f1482cd45db Mon Sep 17 00:00:00 2001 From: Codex Daily Hygiene Date: Fri, 14 Aug 2026 17:55:45 +0200 Subject: [PATCH] Show current Campus work and Issue link --- .tdd/spec-campus-project-work-summary-v1.md | 54 +++ builder/dashboard-assets/script.js | 63 ++- builder/dashboard-server-m4.py | 39 +- .../dashboard_builder/department_campus.py | 94 ++++- .../tests/test_department_campus_projects.py | 16 +- .../test_department_campus_work_summary.py | 370 ++++++++++++++++++ 6 files changed, 617 insertions(+), 19 deletions(-) create mode 100644 .tdd/spec-campus-project-work-summary-v1.md create mode 100644 builder/tests/test_department_campus_work_summary.py diff --git a/.tdd/spec-campus-project-work-summary-v1.md b/.tdd/spec-campus-project-work-summary-v1.md new file mode 100644 index 0000000..1a66c28 --- /dev/null +++ b/.tdd/spec-campus-project-work-summary-v1.md @@ -0,0 +1,54 @@ +# Campus project work summary + GitHub Issue v1 + +Status: LOCKED +Issue: https://github.com/pirajoke/agent-dashboard/issues/34 + +## Goal + +The read-only Pixel Verse Campus project inspector shows a concise description +of the current verified work and a direct GitHub Issue link for owner-view live +tasks, without publishing private task content. + +## Acceptance criteria + +- AC-01: A live project inspector can render `Над чем работаем` from a bounded, + privacy-safe `work_summary` value. +- AC-02: A live project inspector can render one keyboard-operable external + GitHub Issue link whose visible label is `Issue #N`. +- AC-03: Owner-view canonical Bridge tasks derive `work_summary` only from the + typed metadata `objective`, never from raw `description`, `result`, messages, + issue bodies, or logs. +- AC-04: Owner-view issue data is accepted only when metadata contains an + integer `github_issue_number`, a matching `github_issue_url`, and an exact + `github_repo` identity. The URL must be HTTPS `github.com/{owner}/{repo}/issues/N` + with no credentials, port, query, or fragment. +- AC-05: Verified MAIN MANAGER pixel events may carry the same owner-only + fields through the projection after identical validation. +- AC-06: An anonymous public-host `/api/manager/departments` response never + contains `work_summary`, `issue_url`, or `issue_number`; a view authenticated + with the existing dashboard owner token may contain those owner-only fields, + including when accessed through the public host. + +## Edge cases + +- EC-01: Missing summary or issue data hides only the corresponding row. +- EC-02: Summary text is whitespace-normalized and bounded without breaking + the existing responsive inspector. +- EC-03: The link opens in a new tab with `rel="noreferrer"` and remains + read-only and keyboard accessible. + +## Errors and privacy + +- ERR-01: A mismatched issue number/repository, non-GitHub URL, credentials, + port, query, fragment, malformed type, or unsafe summary fails closed. +- ERR-02: Raw task `description`, result, prompt/body, local paths, tokens, + credentials, logs, and private metadata never enter the projection or DOM. +- ERR-03: Existing project details, focus return, Escape close, empty state, + three-lane cap, and public privacy behavior remain unchanged. + +## Constraints + +- No new dependency. +- Source changes stay in `builder/`. +- Read-only UI only; no dispatch, edit, merge, deploy, or publication action. +- Draft PR only. Merge and production deployment are separate stages. diff --git a/builder/dashboard-assets/script.js b/builder/dashboard-assets/script.js index b629dbe..30ecbae 100644 --- a/builder/dashboard-assets/script.js +++ b/builder/dashboard-assets/script.js @@ -109,6 +109,49 @@ unavailable: 'Команда на местах · live-данные временно недоступны', active: 'Показаны свежие подтверждённые события', }; + + function campusOwnerHeaders() { + const headers = {}; + try { + const token = window.localStorage + .getItem('command-center.jarvis-run-token') + ?.trim(); + if (token) headers['X-Dashboard-Run-Token'] = token; + } catch (_error) { + // Storage can be unavailable; the endpoint then returns its public projection. + } + return headers; + } + + function verifiedCampusIssue(event) { + const issueNumber = event?.issue_number; + const issueUrl = event?.issue_url; + if (!Number.isInteger(issueNumber) || issueNumber <= 0 || typeof issueUrl !== 'string') { + return null; + } + try { + const parsed = new URL(issueUrl); + const match = parsed.pathname.match( + /^\/[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*\/issues\/([1-9]\d*)$/, + ); + if ( + parsed.protocol !== 'https:' + || parsed.hostname !== 'github.com' + || parsed.username + || parsed.password + || parsed.port + || parsed.search + || parsed.hash + || !match + || Number(match[1]) !== issueNumber + ) { + return null; + } + } catch (_error) { + return null; + } + return {number: issueNumber, url: issueUrl}; + } let lastTrigger = null; let intervalId = null; let refreshInFlight = false; @@ -229,6 +272,10 @@ ? event.next_step : null; const hasEvidence = Number.isInteger(event?.evidence_count) && event.evidence_count >= 0; + const workSummary = typeof event?.work_summary === 'string' && event.work_summary.trim() + ? event.work_summary + : null; + const issue = verifiedCampusIssue(event); projectDetailFields.project.textContent = folder.dataset.campusProject || '—'; projectDetailFields.department_id.textContent = ( folder.dataset.campusProjectDepartmentLabel || '—' @@ -237,6 +284,13 @@ projectDetailFields.status.textContent = event ? (statusLabels[event.status] || '—') : 'нет активных задач'; + projectDetailFields.work_summary.textContent = workSummary || '—'; + projectDetailFields.issue_url.removeAttribute('href'); + projectDetailFields.issue_url.textContent = '—'; + if (issue) { + projectDetailFields.issue_url.setAttribute('href', issue.url); + projectDetailFields.issue_url.textContent = `Issue #${issue.number}`; + } projectDetailFields.next_step.textContent = nextStep || '—'; projectDetailFields.evidence_count.textContent = hasEvidence ? String(event.evidence_count) @@ -244,7 +298,13 @@ projectLiveOnlyEls.forEach((row) => { const field = row.querySelector('[data-campus-project-detail-field]') ?.dataset.campusProjectDetailField; - row.hidden = field === 'next_step' ? !nextStep : !hasEvidence; + const visible = { + work_summary: Boolean(workSummary), + issue_url: Boolean(issue), + next_step: Boolean(nextStep), + evidence_count: hasEvidence, + }; + row.hidden = !visible[field]; }); projectDetailEl.hidden = false; projectDetailCloseEl?.focus(); @@ -480,6 +540,7 @@ const response = await fetch('/api/manager/departments', { cache: 'no-store', signal: controller.signal, + headers: campusOwnerHeaders(), }); if (!response.ok) throw new Error('unavailable'); const payload = await response.json(); diff --git a/builder/dashboard-server-m4.py b/builder/dashboard-server-m4.py index cd59c76..3e22f83 100644 --- a/builder/dashboard-server-m4.py +++ b/builder/dashboard-server-m4.py @@ -1250,7 +1250,7 @@ def _campus_bridge_event(task: dict) -> dict | None: if not isinstance(task_id, str): return None zone = DEPARTMENT_ZONES[project["department_id"]] - return { + event = { "event_id": task_id, "task_id": task_id, "department_id": project["department_id"], @@ -1265,16 +1265,27 @@ def _campus_bridge_event(task: dict) -> dict | None: "ephemeral": True, "zone_id": zone["zone_id"], } + metadata = _manager_metadata(task) + event["work_summary"] = metadata.get("objective") + event["github_repo"] = metadata.get("github_repo") + event["github_issue_number"] = metadata.get("github_issue_number") + event["github_issue_url"] = metadata.get("github_issue_url") + return event -def _department_campus_payload(data: object, *, now: datetime | None = None) -> dict: +def _department_campus_payload( + data: object, + *, + now: datetime | None = None, + owner_view: bool = False, +) -> dict: """Project a verified manager snapshot or safe canonical Bridge tasks.""" current = now or datetime.now(timezone.utc) if current.tzinfo is None: current = current.replace(tzinfo=timezone.utc) current = current.astimezone(timezone.utc) if not isinstance(data, dict) or not isinstance(data.get("tasks"), list): - return department_campus_projection(None, now=current) + return department_campus_projection(None, now=current, owner_view=owner_view) candidates: list[tuple[int, datetime, list]] = [] malformed_verified_snapshot = False @@ -1307,7 +1318,7 @@ def _department_campus_payload(data: object, *, now: datetime | None = None) -> if not candidates: if malformed_verified_snapshot: - return department_campus_projection(None, now=current) + return department_campus_projection(None, now=current, owner_view=owner_view) events = [ event for task in data["tasks"] @@ -1315,13 +1326,23 @@ def _department_campus_payload(data: object, *, now: datetime | None = None) -> for event in [_campus_bridge_event(task)] if event is not None ] - return department_campus_projection(events, now=current, max_tasks=3) + return department_campus_projection( + events, + now=current, + max_tasks=3, + owner_view=owner_view, + ) # max() preserves the first source item when timestamps tie. _, snapshot_time, events = max(candidates, key=lambda item: item[1]) if (current - snapshot_time).total_seconds() > 30 * 60: return _department_campus_state("stale", now=current) - return department_campus_projection(events, now=current, max_tasks=3) + return department_campus_projection( + events, + now=current, + max_tasks=3, + owner_view=owner_view, + ) def _runtime_asset_block(filename: str, start_marker: str, end_marker: str) -> str: @@ -2244,7 +2265,11 @@ def do_GET(self): if parsed.path == '/api/manager/departments': try: data = _bridge_request("GET", "/api/tasks?limit=24&include_messages=1") - self._json_response(200, _department_campus_payload(data)) + if self._dashboard_run_authorized(): + payload = _department_campus_payload(data, owner_view=True) + else: + payload = _department_campus_payload(data) + self._json_response(200, payload) except Exception: self._json_response( 200, diff --git a/builder/dashboard_builder/department_campus.py b/builder/dashboard_builder/department_campus.py index bfc12a6..8cfe469 100644 --- a/builder/dashboard_builder/department_campus.py +++ b/builder/dashboard_builder/department_campus.py @@ -268,6 +268,11 @@ def campus_project_for_event(event: object) -> MappingProxyType | None: "ephemeral", "zone_id", ) +_OWNER_EVENT_FIELDS = ( + "work_summary", + "issue_number", + "issue_url", +) _FRESH_SECONDS = 30 * 60 _SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,95}$") _UNSAFE_TEXT = re.compile( @@ -412,6 +417,55 @@ def _safe_text(value: object, *, limit: int = 180) -> str: return _bounded_grapheme_text(value, limit) +def _safe_owner_summary(value: object, *, limit: int = 240) -> str | None: + """Return a bounded owner summary, rejecting rather than repairing unsafe input.""" + if not isinstance(value, str): + return None + normalized = unicodedata.normalize("NFKC", value) + summary = " ".join(normalized.strip().split()) + if ( + not summary + or _strip_unsafe_formatting(summary) != summary + or _UNSAFE_TEXT.search(summary) + ): + return None + bounded = _bounded_grapheme_text(summary, limit) + return bounded or None + + +def _validated_owner_fields(event: dict[str, Any]) -> dict[str, Any]: + """Validate optional owner-only fields independently and fail closed.""" + owner_fields: dict[str, Any] = {} + summary = _safe_owner_summary(event.get("work_summary")) + if summary is not None: + owner_fields["work_summary"] = summary + + repo = event.get("github_repo") + issue_number = event.get("github_issue_number") + issue_url = event.get("github_issue_url") + repo_parts = repo.split("/", 1) if isinstance(repo, str) else () + github_owner = repo_parts[0] if len(repo_parts) == 2 else "" + github_name = repo_parts[1] if len(repo_parts) == 2 else "" + if ( + not isinstance(repo, str) + or not re.fullmatch( + r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?", + github_owner, + ) + or not re.fullmatch(r"[A-Za-z0-9._-]{1,100}", github_name) + or github_name in {".", ".."} + or type(issue_number) is not int + or issue_number <= 0 + or not isinstance(issue_url, str) + or issue_url != f"https://github.com/{repo}/issues/{issue_number}" + ): + return owner_fields + + owner_fields["issue_number"] = issue_number + owner_fields["issue_url"] = issue_url + return owner_fields + + def _empty_projection(state: str, now: datetime) -> dict[str, Any]: return { "state": state, @@ -427,6 +481,7 @@ def _validated_event( event: object, *, now: datetime, + owner_view: bool = False, ) -> tuple[dict[str, Any] | None, str, datetime | None]: if not isinstance(event, dict): return None, "invalid", None @@ -477,6 +532,8 @@ def _validated_event( "ephemeral": True, "zone_id": zone["zone_id"], } + if owner_view is True: + public.update(_validated_owner_fields(event)) return public, "valid", updated @@ -485,8 +542,9 @@ def department_campus_projection( *, now: datetime, max_tasks: int = 3, + owner_view: bool = False, ) -> dict[str, Any]: - """Return a strict, fresh public projection of verified pixel events.""" + """Return a strict projection, optionally including validated owner fields.""" if not isinstance(events, list): return _empty_projection("unavailable", now) if not events: @@ -495,7 +553,11 @@ def department_campus_projection( validated: list[tuple[int, dict[str, Any], datetime]] = [] saw_stale = False for index, raw_event in enumerate(events): - event, validation_state, updated = _validated_event(raw_event, now=now) + event, validation_state, updated = _validated_event( + raw_event, + now=now, + owner_view=owner_view, + ) saw_stale = saw_stale or validation_state == "stale" if event is not None and updated is not None: validated.append((index, event, updated)) @@ -531,7 +593,11 @@ def department_campus_projection( "visible_task_count": len(visible_tasks), "omitted_task_count": max(0, len(all_tasks) - len(visible_tasks)), "events": [ - {field: event[field] for field in _PUBLIC_EVENT_FIELDS} + { + field: event[field] + for field in _PUBLIC_EVENT_FIELDS + _OWNER_EVENT_FIELDS + if field in event + } for event in visible_events ], "privacy": "public_projection", @@ -657,18 +723,28 @@ def build_department_campus_html() -> str: ("department_id", "Отдел", False), ("agent_id", "Ответственный агент", False), ("status", "Статус", False), + ("work_summary", "Над чем работаем", True), + ("issue_url", "GitHub Issue", True), ("next_step", "Следующий безопасный шаг", True), ("evidence_count", "Подтверждения", True), ) - project_detail_rows = "".join( - ( + project_detail_rows = [] + for field, label, live_only in project_details: + row_start = ( '") + project_detail_rows_html = "".join(project_detail_rows) return f"""
@@ -711,7 +787,7 @@ def build_department_campus_html() -> str:
Папка проекта

Сведения о проекте · только просмотр

-
{project_detail_rows}
+
{project_detail_rows_html}