instar 1.3.29 → 1.3.30
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.
- package/dashboard/index.html +112 -0
- package/dashboard/process-health.js +504 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +17 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/monitoring/FailureLedger.d.ts +11 -3
- package/dist/monitoring/FailureLedger.d.ts.map +1 -1
- package/dist/monitoring/FailureLedger.js +22 -5
- package/dist/monitoring/FailureLedger.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +6 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +48 -4
- package/dist/server/routes.js.map +1 -1
- package/package.json +2 -1
- package/scripts/analyze-release.js +21 -7
- package/src/data/builtin-manifest.json +62 -62
- package/src/scaffold/templates.ts +6 -0
- package/upgrades/1.3.30.md +35 -0
- package/upgrades/side-effects/process-health-dashboard-tab.md +59 -0
- package/upgrades/side-effects/release-readiness-visibility-pr1.md +26 -0
package/dashboard/index.html
CHANGED
|
@@ -777,6 +777,50 @@
|
|
|
777
777
|
display: none !important;
|
|
778
778
|
}
|
|
779
779
|
|
|
780
|
+
/* ── process-health tab (PROCESS-HEALTH-DASHBOARD-TAB-SPEC §4.1) ──
|
|
781
|
+
Large, readable, calm. No monospace, no tables, no alarm colors.
|
|
782
|
+
Measurable bars: headline ≥24px, section headers ≥20px, body ≥17px,
|
|
783
|
+
line-height 1.5–1.6. One neutral accent token, used ONLY for the
|
|
784
|
+
maturation "you're here" marker. */
|
|
785
|
+
.ph-root {
|
|
786
|
+
--ph-accent: #4ade80;
|
|
787
|
+
max-width: 760px;
|
|
788
|
+
font-size: 18px;
|
|
789
|
+
line-height: 1.55;
|
|
790
|
+
color: var(--text-bright);
|
|
791
|
+
}
|
|
792
|
+
.ph-title { font-size: 22px; font-weight: 600; color: var(--text-bright); line-height: 1.5; }
|
|
793
|
+
.ph-stamp { font-size: 14px; color: var(--text-dim); }
|
|
794
|
+
.ph-headline-wrap { margin: 4px 0 8px; }
|
|
795
|
+
.ph-headline { font-size: 26px; font-weight: 600; line-height: 1.5; color: var(--text-bright); }
|
|
796
|
+
.ph-subline { font-size: 18px; color: var(--text-dim); margin-top: 8px; line-height: 1.55; }
|
|
797
|
+
.ph-section { display: flex; flex-direction: column; gap: 10px; }
|
|
798
|
+
.ph-h { font-size: 20px; font-weight: 600; color: var(--text-bright); line-height: 1.5; }
|
|
799
|
+
.ph-empty { font-size: 18px; color: var(--text-dim); line-height: 1.6; }
|
|
800
|
+
.ph-card {
|
|
801
|
+
padding: 14px 16px; border: 1px solid var(--border); border-radius: 10px;
|
|
802
|
+
display: flex; flex-direction: column; gap: 6px;
|
|
803
|
+
}
|
|
804
|
+
.ph-card-summary { font-size: 18px; color: var(--text-bright); line-height: 1.5; }
|
|
805
|
+
.ph-card-frame { font-size: 15px; color: var(--text-dim); font-style: italic; }
|
|
806
|
+
.ph-card-rec { font-size: 17px; color: var(--text); line-height: 1.55; }
|
|
807
|
+
.ph-card-evidence { font-size: 15px; color: var(--text-dim); }
|
|
808
|
+
.ph-record { font-size: 18px; color: var(--text); line-height: 1.6; padding: 5px 0; }
|
|
809
|
+
.ph-record-inert { color: var(--text-dim); }
|
|
810
|
+
.ph-stage { display: flex; align-items: baseline; gap: 10px; font-size: 18px; padding: 3px 0; color: var(--text-dim); line-height: 1.5; }
|
|
811
|
+
.ph-stage-here { color: var(--text-bright); }
|
|
812
|
+
.ph-stage-glyph { color: var(--text-dim); }
|
|
813
|
+
.ph-stage-here .ph-stage-glyph { color: var(--ph-accent); }
|
|
814
|
+
.ph-stage-marker { color: var(--ph-accent); font-size: 15px; }
|
|
815
|
+
.ph-stage-note { color: var(--text-dim); font-size: 15px; }
|
|
816
|
+
.ph-detail { margin-top: 4px; }
|
|
817
|
+
.ph-detail-summary { font-size: 17px; color: var(--text-dim); cursor: pointer; }
|
|
818
|
+
.ph-detail-body { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
|
|
819
|
+
.ph-detail-row { font-size: 17px; color: var(--text); line-height: 1.55; }
|
|
820
|
+
.ph-operator { margin-top: 14px; }
|
|
821
|
+
.ph-operator-summary { font-size: 15px; color: var(--text-dim); cursor: pointer; }
|
|
822
|
+
.ph-operator-hint { font-size: 16px; color: var(--text-dim); margin-top: 8px; line-height: 1.55; }
|
|
823
|
+
|
|
780
824
|
/* ── File viewer ─────────────────────────────────────── */
|
|
781
825
|
.files-container {
|
|
782
826
|
display: flex;
|
|
@@ -2529,6 +2573,7 @@
|
|
|
2529
2573
|
<button class="tab" data-tab="tokens" onclick="switchTab('tokens')">Tokens</button>
|
|
2530
2574
|
<button class="tab" data-tab="threadline" onclick="switchTab('threadline')">Threadline</button>
|
|
2531
2575
|
<button class="tab" data-tab="evidence" onclick="switchTab('evidence')">Evidence</button>
|
|
2576
|
+
<button class="tab" data-tab="process-health" onclick="switchTab('process-health')">Process Health</button>
|
|
2532
2577
|
</nav>
|
|
2533
2578
|
</div>
|
|
2534
2579
|
<div class="vital-signs" id="vitalSigns">
|
|
@@ -3012,6 +3057,31 @@
|
|
|
3012
3057
|
|
|
3013
3058
|
<!-- Evidence Tab (WikiClaim Phase 4) — per-entity evidence + reverse "what cites this?" view.
|
|
3014
3059
|
Spec: docs/specs/OPENCLAW-IMPORT-WIKICLAIM-EVIDENCE-SPEC.md § Phase 4 (line 343). -->
|
|
3060
|
+
<!-- process-health tab -->
|
|
3061
|
+
<div id="processHealthPanel" class="ph-root" style="display:none;flex-direction:column;padding:28px;gap:24px;overflow-y:auto">
|
|
3062
|
+
<div style="display:flex;justify-content:space-between;align-items:baseline">
|
|
3063
|
+
<h2 class="ph-title">Process Health</h2>
|
|
3064
|
+
<div id="phStamp" class="ph-stamp" aria-live="polite"></div>
|
|
3065
|
+
</div>
|
|
3066
|
+
<div id="phHeadline" class="ph-headline-wrap" aria-live="polite"></div>
|
|
3067
|
+
<section class="ph-section">
|
|
3068
|
+
<h3 class="ph-h">Patterns to know about</h3>
|
|
3069
|
+
<div id="phPatterns"></div>
|
|
3070
|
+
</section>
|
|
3071
|
+
<section class="ph-section">
|
|
3072
|
+
<h3 class="ph-h">What’s been captured</h3>
|
|
3073
|
+
<div id="phCaptured"></div>
|
|
3074
|
+
</section>
|
|
3075
|
+
<section class="ph-section">
|
|
3076
|
+
<h3 class="ph-h">Maturation</h3>
|
|
3077
|
+
<div id="phMaturation"></div>
|
|
3078
|
+
</section>
|
|
3079
|
+
<details class="ph-detail">
|
|
3080
|
+
<summary class="ph-detail-summary">Detail</summary>
|
|
3081
|
+
<div id="phDetail" class="ph-detail-body"></div>
|
|
3082
|
+
</details>
|
|
3083
|
+
</div>
|
|
3084
|
+
|
|
3015
3085
|
<div id="evidenceTab" style="display:none;flex-direction:column;padding:20px;gap:16px;overflow-y:auto">
|
|
3016
3086
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
3017
3087
|
<h2 style="margin:0">Evidence</h2>
|
|
@@ -4244,6 +4314,13 @@
|
|
|
4244
4314
|
display: ['flex'],
|
|
4245
4315
|
onActivate: null,
|
|
4246
4316
|
},
|
|
4317
|
+
{
|
|
4318
|
+
id: 'process-health',
|
|
4319
|
+
panels: ['processHealthPanel'],
|
|
4320
|
+
display: ['flex'],
|
|
4321
|
+
onActivate: () => { if (typeof startProcessHealth === 'function') startProcessHealth(); },
|
|
4322
|
+
onDeactivate: () => { if (typeof stopProcessHealth === 'function') stopProcessHealth(); },
|
|
4323
|
+
},
|
|
4247
4324
|
{
|
|
4248
4325
|
id: 'secrets',
|
|
4249
4326
|
panels: ['secretsPanel'],
|
|
@@ -7973,13 +8050,48 @@
|
|
|
7973
8050
|
return m + 'm';
|
|
7974
8051
|
}
|
|
7975
8052
|
|
|
8053
|
+
// ── Process Health tab (PROCESS-HEALTH-DASHBOARD-TAB-SPEC) ──
|
|
8054
|
+
// Lazy dynamic-import keeps the controller in this (classic) scope so it can
|
|
8055
|
+
// read `token`; the module ships the pure renderers + the polling controller.
|
|
8056
|
+
let __phController = null;
|
|
8057
|
+
let __phModule = null;
|
|
8058
|
+
async function startProcessHealth() {
|
|
8059
|
+
if (!__phModule) {
|
|
8060
|
+
try { __phModule = await import('/dashboard/process-health.js'); }
|
|
8061
|
+
catch (e) { console.error('[process-health] module load failed', e); return; }
|
|
8062
|
+
}
|
|
8063
|
+
if (!__phController) {
|
|
8064
|
+
const els = {
|
|
8065
|
+
headline: document.getElementById('phHeadline'),
|
|
8066
|
+
patterns: document.getElementById('phPatterns'),
|
|
8067
|
+
captured: document.getElementById('phCaptured'),
|
|
8068
|
+
maturation: document.getElementById('phMaturation'),
|
|
8069
|
+
detail: document.getElementById('phDetail'),
|
|
8070
|
+
stamp: document.getElementById('phStamp'),
|
|
8071
|
+
};
|
|
8072
|
+
// Raw fetch (NOT apiFetch) so the controller sees 304/503/ETag itself.
|
|
8073
|
+
const fetchImpl = (url, opts = {}) => fetch(url, {
|
|
8074
|
+
headers: { 'Authorization': `Bearer ${token}`, ...(opts.headers || {}) },
|
|
8075
|
+
signal: opts.signal,
|
|
8076
|
+
});
|
|
8077
|
+
__phController = __phModule.createController({ doc: document, els, fetchImpl });
|
|
8078
|
+
}
|
|
8079
|
+
__phController.start();
|
|
8080
|
+
}
|
|
8081
|
+
function stopProcessHealth() {
|
|
8082
|
+
if (__phController) __phController.stop();
|
|
8083
|
+
}
|
|
8084
|
+
|
|
7976
8085
|
// Handle tab visibility — close SSE when tab is backgrounded
|
|
7977
8086
|
document.addEventListener('visibilitychange', () => {
|
|
7978
8087
|
if (document.hidden) {
|
|
7979
8088
|
disconnectJobsSSE();
|
|
8089
|
+
if (currentTab === 'process-health') stopProcessHealth();
|
|
7980
8090
|
} else if (currentTab === 'jobs') {
|
|
7981
8091
|
connectJobsSSE();
|
|
7982
8092
|
loadJobs(); // Resync on return
|
|
8093
|
+
} else if (currentTab === 'process-health') {
|
|
8094
|
+
startProcessHealth(); // resume on return: one immediate refresh + re-arm
|
|
7983
8095
|
}
|
|
7984
8096
|
// Always refresh vital signs on tab focus
|
|
7985
8097
|
if (!document.hidden) pollVitalSigns();
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
// Process Health tab — a calm, human-readable read surface for the Failure-Learning Loop.
|
|
2
|
+
// Spec: docs/specs/PROCESS-HEALTH-DASHBOARD-TAB-SPEC.md (v4, converged).
|
|
3
|
+
//
|
|
4
|
+
// Browser-native ESM (no build step; served at /dashboard/process-health.js and
|
|
5
|
+
// loaded by index.html via <script type="module">). The pure functions are
|
|
6
|
+
// exported so the 3-tier jsdom tests exercise the SHIPPED code, not a copy; the
|
|
7
|
+
// controller is also attached to window.ProcessHealth so the (classic) index.html
|
|
8
|
+
// script can drive start/stop on tab activation. This is one self-contained
|
|
9
|
+
// module for the new tab — NOT a modularization of the SPA (that is §10-deferred).
|
|
10
|
+
//
|
|
11
|
+
// Load-bearing safety contract (§4.6): every dynamic value flows through
|
|
12
|
+
// sanitizeForDisplay before the DOM; all DOM writes are textContent only; all
|
|
13
|
+
// presentation chrome is renderer-OWNED static literals (CHROME below) so no
|
|
14
|
+
// attacker codepoint can ever sit where chrome is read.
|
|
15
|
+
|
|
16
|
+
// ── Renderer-owned static chrome (§4.6 r8 belt-and-suspenders, adversarial NEW-1) ──
|
|
17
|
+
// These glyphs are the tab's presentation alphabet. They live HERE as static
|
|
18
|
+
// literals and are NEVER sourced from data — and sanitizeForDisplay strips this
|
|
19
|
+
// exact class from every dynamic value — so a record field can never impersonate
|
|
20
|
+
// a marker/separator/caret.
|
|
21
|
+
export const CHROME = Object.freeze({
|
|
22
|
+
hereMarker: '← you’re here', // "← you're here"
|
|
23
|
+
sep: ' · ', // " · " record-sentence separator
|
|
24
|
+
caret: '▾', // "▾" drawer caret
|
|
25
|
+
stageDone: '●', // "●" completed stage
|
|
26
|
+
stageHere: '●', // "●" current stage (paired with hereMarker)
|
|
27
|
+
stageFuture: '○', // "○" future stage
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const CAPS = { summary: 240, recommendation: 320, label: 64, detail: 2048 };
|
|
31
|
+
|
|
32
|
+
// Structural presentation-glyph class (NOT an enumerated denylist). Strips, after
|
|
33
|
+
// the NFKC fold, every codepoint in the symbol classes the chrome draws from, so
|
|
34
|
+
// confusable variants (✔ vs ✓, ⇒ vs →, ●︎ with a variation selector) cannot slip
|
|
35
|
+
// past: \p{So} (other-symbol) + arrows + geometric + box-drawing + dingbats +
|
|
36
|
+
// variation-selectors + the bullet/middot set.
|
|
37
|
+
// \p{So} (other-symbol: ★ ● ✓ ✔ ℹ 🔒 …) + arrows U+2190–21FF + geometric
|
|
38
|
+
// U+25A0–25FF + box-drawing U+2500–257F + dingbats U+2700–27BF + variation
|
|
39
|
+
// selectors U+FE00–FE0F + bullet/middot set (U+2022, U+00B7, U+2027, U+2043).
|
|
40
|
+
// Built via RegExp(...) with explicit code points — no invisible source chars.
|
|
41
|
+
const CHROME_GLYPH_RE = new RegExp(
|
|
42
|
+
'[\\p{So}\\u2190-\\u21FF\\u25A0-\\u25FF\\u2500-\\u257F\\u2700-\\u27BF\\uFE00-\\uFE0F\\u2022\\u00B7\\u2027\\u2043]',
|
|
43
|
+
'gu',
|
|
44
|
+
);
|
|
45
|
+
// Invisible-character classes (also explicit code points, no literal controls):
|
|
46
|
+
// C0/C1 controls except \t (U+0009) and \n (U+000A); Unicode bidi-control marks.
|
|
47
|
+
const CONTROL_RE = new RegExp('[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F-\\u009F]', 'g');
|
|
48
|
+
const BIDI_RE = new RegExp('[\\u202A-\\u202E\\u2066-\\u2069]', 'g');
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* §4.6 sanitize contract. Rules: 1 null-coerce; 2 NFKC-fold (collapses full-width/
|
|
52
|
+
* variant confusables BEFORE stripping); 3 strip C0/C1 controls (keep \n,\t);
|
|
53
|
+
* 4 strip bidi-control; 5 collapse newlines + cap whitespace runs; 8 structural
|
|
54
|
+
* chrome-glyph strip; 6 grapheme-safe length cap. (Strip precedes the cap so the
|
|
55
|
+
* cap reflects final visible length — closes the cosmetic under-cap the reviewer
|
|
56
|
+
* flagged; safety is unaffected by the 6/8 order.)
|
|
57
|
+
*/
|
|
58
|
+
export function sanitizeForDisplay(value, fieldKind = 'summary') {
|
|
59
|
+
let s = value == null ? '' : String(value); // 1
|
|
60
|
+
s = s.normalize('NFKC'); // 2
|
|
61
|
+
s = s.replace(CONTROL_RE, ''); // 3 (keep \n=0A, \t=09)
|
|
62
|
+
s = s.replace(BIDI_RE, ''); // 4 bidi-control
|
|
63
|
+
s = s.replace(/\n{2,}/g, '\n').replace(/[ \t]{5,}/g, ' '); // 5
|
|
64
|
+
s = s.replace(CHROME_GLYPH_RE, ''); // 8 structural chrome-glyph strip
|
|
65
|
+
s = capGraphemes(s, CAPS[fieldKind] ?? CAPS.summary); // 6 grapheme-safe cap
|
|
66
|
+
return s;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function capGraphemes(s, max) {
|
|
70
|
+
if (s.length <= max) return s; // UTF-16 length ≥ grapheme count → safe fast path
|
|
71
|
+
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
|
|
72
|
+
const arr = Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(s), (x) => x.segment);
|
|
73
|
+
if (arr.length <= max) return s;
|
|
74
|
+
return arr.slice(0, max - 1).join('') + '…';
|
|
75
|
+
}
|
|
76
|
+
// Fallback: never cut between surrogate halves.
|
|
77
|
+
let cut = max - 1;
|
|
78
|
+
const c = s.charCodeAt(cut - 1);
|
|
79
|
+
if (c >= 0xd800 && c <= 0xdbff) cut -= 1;
|
|
80
|
+
return s.slice(0, cut) + '…';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Heuristic mixed-script flag (§4.6 r7): Latin letters co-occurring with confusable
|
|
85
|
+
* Cyrillic/Greek. The caller renders such a row inertly (no special glyph) — we
|
|
86
|
+
* never add chrome; this only signals.
|
|
87
|
+
*/
|
|
88
|
+
export function isMixedScript(value) {
|
|
89
|
+
const s = value == null ? '' : String(value);
|
|
90
|
+
const latin = /[A-Za-z]/.test(s);
|
|
91
|
+
const confusable = /[Ѐ-ӿͰ-Ͽ]/.test(s);
|
|
92
|
+
return latin && confusable;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* §4.6 safeUrl. v1 renders NO dynamic URL-bearing attributes (defense-in-depth):
|
|
97
|
+
* allow relative (`/`,`#`) and same-origin http(s) only; reject javascript:/data:/
|
|
98
|
+
* vbscript:/file: and all off-origin hosts → empty string.
|
|
99
|
+
*/
|
|
100
|
+
export function safeUrl(value) {
|
|
101
|
+
const s = (value == null ? '' : String(value)).trim();
|
|
102
|
+
if (s === '') return '';
|
|
103
|
+
if (s.startsWith('/') || s.startsWith('#')) return s;
|
|
104
|
+
let u;
|
|
105
|
+
try {
|
|
106
|
+
const base = typeof location !== 'undefined' && location.href ? location.href : 'http://localhost/';
|
|
107
|
+
u = new URL(s, base);
|
|
108
|
+
} catch {
|
|
109
|
+
return '';
|
|
110
|
+
}
|
|
111
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
|
|
112
|
+
const origin = typeof location !== 'undefined' && location.origin ? location.origin : 'http://localhost';
|
|
113
|
+
if (u.origin !== origin) return '';
|
|
114
|
+
return u.href;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Friendly wording (§4.2c) ───────────────────────────────────────────────
|
|
118
|
+
const CATEGORY_WORDS = {
|
|
119
|
+
concurrency: 'A concurrency issue',
|
|
120
|
+
'config-parse': 'A config problem',
|
|
121
|
+
'type-error': 'A type error',
|
|
122
|
+
'logic-error': 'A logic bug',
|
|
123
|
+
'test-failure': 'A failing test',
|
|
124
|
+
'build-failure': 'A build break',
|
|
125
|
+
'integration-gap': 'An integration gap',
|
|
126
|
+
regression: 'A regression',
|
|
127
|
+
'wiring-gap': 'A wiring gap',
|
|
128
|
+
unknown: 'An issue',
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export function friendlyCategory(category) {
|
|
132
|
+
const key = typeof category === 'string' ? category : 'unknown';
|
|
133
|
+
return CATEGORY_WORDS[key] || 'An issue';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// initiativeId → human label. NEVER expose the raw initiativeId (§4.2c). Seeded
|
|
137
|
+
// with the known instar initiatives; unmapped IDs fall back to a generic phrase.
|
|
138
|
+
export const ATTRIBUTION_LABELS = {
|
|
139
|
+
'failure-learning-loop': 'the failure-learning loop',
|
|
140
|
+
'process-health-dashboard-tab': 'the process-health tab',
|
|
141
|
+
'ledger-spine': 'the ledger spine',
|
|
142
|
+
'threadline-keystone': 'the agent-network keystone',
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export function attributionLabel(initiativeId, labelMap = ATTRIBUTION_LABELS) {
|
|
146
|
+
if (typeof initiativeId === 'string' && labelMap[initiativeId]) return labelMap[initiativeId];
|
|
147
|
+
return 'a tracked feature';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function relativeTime(iso, now = Date.now()) {
|
|
151
|
+
const t = typeof iso === 'string' ? Date.parse(iso) : NaN;
|
|
152
|
+
if (Number.isNaN(t)) return 'recently';
|
|
153
|
+
const sec = Math.max(0, Math.floor((now - t) / 1000));
|
|
154
|
+
if (sec < 90) return 'just now';
|
|
155
|
+
const min = Math.floor(sec / 60);
|
|
156
|
+
if (min < 60) return `about ${min} minute${min === 1 ? '' : 's'} ago`;
|
|
157
|
+
const hr = Math.floor(min / 60);
|
|
158
|
+
if (hr < 24) return `about ${hr} hour${hr === 1 ? '' : 's'} ago`;
|
|
159
|
+
const day = Math.floor(hr / 24);
|
|
160
|
+
if (day < 14) return `about ${spellSmall(day)} day${day === 1 ? '' : 's'} ago`;
|
|
161
|
+
const wk = Math.floor(day / 7);
|
|
162
|
+
if (wk < 8) return `about ${spellSmall(wk)} week${wk === 1 ? '' : 's'} ago`;
|
|
163
|
+
const mo = Math.floor(day / 30);
|
|
164
|
+
return `about ${spellSmall(mo)} month${mo === 1 ? '' : 's'} ago`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const SMALL = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'];
|
|
168
|
+
function spellSmall(n) {
|
|
169
|
+
return n >= 0 && n <= 10 ? SMALL[n] : String(n);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── DOM helpers (textContent ONLY — never innerHTML; §4.6) ──────────────────
|
|
173
|
+
function el(doc, tag, cls, text) {
|
|
174
|
+
const node = doc.createElement(tag);
|
|
175
|
+
if (cls) node.setAttribute('class', cls); // class is a static literal
|
|
176
|
+
if (text != null) node.textContent = text; // dynamic text → textContent ONLY
|
|
177
|
+
return node;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Renderers (pure data→DOM; one section per function) ─────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Headline (§4.2a, informational — NOT a verdict) + staleness escalation (§4.1).
|
|
184
|
+
* staleness keyed on the headline endpoint's OWN freshness (adversarial NEW-3).
|
|
185
|
+
*/
|
|
186
|
+
export function renderHeadline(doc, target, { failures, stale, staleAgeMin }) {
|
|
187
|
+
if (!target) return;
|
|
188
|
+
target.replaceChildren();
|
|
189
|
+
if (stale) {
|
|
190
|
+
target.appendChild(
|
|
191
|
+
el(doc, 'div', 'ph-headline', `Connection paused — showing the last view${staleAgeMin != null ? ` from ${staleAgeMin}m ago` : ''}`),
|
|
192
|
+
);
|
|
193
|
+
target.appendChild(el(doc, 'div', 'ph-subline', "Couldn't refresh just now — showing the last good view. Will retry."));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const list = Array.isArray(failures) ? failures : [];
|
|
197
|
+
const n = list.length;
|
|
198
|
+
const open = list.filter((f) => f && f.status !== 'resolved' && f.status !== 'closed').length;
|
|
199
|
+
target.appendChild(el(doc, 'div', 'ph-headline', `Watching — ${n} issue${n === 1 ? '' : 's'} recorded so far`));
|
|
200
|
+
const subline = n === 0 ? 'Nothing recorded yet — capture-only mode' : open === 0 ? 'all linked to a known cause · capture-only mode' : `${open} still being traced · capture-only mode`;
|
|
201
|
+
target.appendChild(el(doc, 'div', 'ph-subline', subline));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Disabled (503) state (§4.5). Pinned copy + operator hint as PLAIN PROSE — no
|
|
206
|
+
* `<code>`, no monospace, no config-key string (reconciles §2/§4.5/§6.3).
|
|
207
|
+
*/
|
|
208
|
+
export function renderDisabled(doc, els) {
|
|
209
|
+
if (els.headline) {
|
|
210
|
+
els.headline.replaceChildren();
|
|
211
|
+
els.headline.appendChild(el(doc, 'div', 'ph-headline', 'Process Health isn’t turned on for this agent yet.'));
|
|
212
|
+
els.headline.appendChild(el(doc, 'div', 'ph-subline', 'Once it is, this page will show what it’s noticing.'));
|
|
213
|
+
const det = doc.createElement('details');
|
|
214
|
+
det.setAttribute('class', 'ph-operator');
|
|
215
|
+
const sum = doc.createElement('summary');
|
|
216
|
+
sum.setAttribute('class', 'ph-operator-summary');
|
|
217
|
+
sum.textContent = 'for operators';
|
|
218
|
+
det.appendChild(sum);
|
|
219
|
+
det.appendChild(el(doc, 'p', 'ph-operator-hint', 'An operator can enable it by turning the failure-learning monitor on in the agent settings.'));
|
|
220
|
+
els.headline.appendChild(det);
|
|
221
|
+
}
|
|
222
|
+
for (const k of ['patterns', 'captured', 'maturation', 'detail']) {
|
|
223
|
+
if (els[k]) els[k].replaceChildren();
|
|
224
|
+
}
|
|
225
|
+
if (els.stamp) els.stamp.textContent = '';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Patterns cards (§4.2b — awareness-only, NO action authority). */
|
|
229
|
+
export function renderPatterns(doc, target, insights) {
|
|
230
|
+
if (!target) return;
|
|
231
|
+
target.replaceChildren();
|
|
232
|
+
const list = Array.isArray(insights) ? insights : [];
|
|
233
|
+
if (list.length === 0) {
|
|
234
|
+
target.appendChild(
|
|
235
|
+
el(doc, 'p', 'ph-empty', 'Nothing flagged yet. The monitor needs to see a wider variety of issues before it surfaces a pattern — that’s expected this early.'),
|
|
236
|
+
);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
for (const ins of list) {
|
|
240
|
+
const card = el(doc, 'div', 'ph-card');
|
|
241
|
+
card.appendChild(el(doc, 'div', 'ph-card-summary', sanitizeForDisplay(ins && ins.summary, 'summary')));
|
|
242
|
+
// Renderer-owned framing line (static literal — NOT a data-restateable prefix).
|
|
243
|
+
card.appendChild(el(doc, 'div', 'ph-card-frame', 'A pattern the monitor noticed — verify before acting.'));
|
|
244
|
+
if (ins && ins.recommendation) {
|
|
245
|
+
card.appendChild(el(doc, 'div', 'ph-card-rec', sanitizeForDisplay(ins.recommendation, 'recommendation')));
|
|
246
|
+
}
|
|
247
|
+
const ds = ins && Number.isFinite(ins.distinctSessions) ? ins.distinctSessions : 0;
|
|
248
|
+
card.appendChild(el(doc, 'div', 'ph-card-evidence', `Seen across ${ds} change${ds === 1 ? '' : 's'}.`));
|
|
249
|
+
target.appendChild(card);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** What's been captured (§4.2c — operator-safe subset only, sentences not rows). */
|
|
254
|
+
export function renderCaptured(doc, target, failures, labelMap = ATTRIBUTION_LABELS, now = Date.now()) {
|
|
255
|
+
if (!target) return;
|
|
256
|
+
target.replaceChildren();
|
|
257
|
+
const list = Array.isArray(failures) ? failures.slice(0, 10) : [];
|
|
258
|
+
if (list.length === 0) {
|
|
259
|
+
target.appendChild(el(doc, 'p', 'ph-empty', 'No issues recorded yet — that just means nothing has come through since this was turned on.'));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
for (const f of list) {
|
|
263
|
+
if (!f) continue;
|
|
264
|
+
const cat = friendlyCategory(f.category);
|
|
265
|
+
const summary = sanitizeForDisplay(f.summary, 'summary');
|
|
266
|
+
const label = attributionLabel(f.initiativeId, labelMap); // NEVER raw initiativeId
|
|
267
|
+
const when = relativeTime(f.detectedAt, now);
|
|
268
|
+
// ONE plain-English sentence. Separator + wording are renderer-owned literals.
|
|
269
|
+
const text = `${cat}: ${summary}${CHROME.sep}attributed to ${label}${CHROME.sep}${when}.`;
|
|
270
|
+
const row = el(doc, 'p', 'ph-record', text);
|
|
271
|
+
if (isMixedScript(f.summary)) row.setAttribute('class', 'ph-record ph-record-inert');
|
|
272
|
+
target.appendChild(row);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Maturation track (§4.2d). 4th stage is permanently-future (no per-agent flag). */
|
|
277
|
+
const STAGES = [
|
|
278
|
+
{ key: 'dark', label: 'Dark' },
|
|
279
|
+
{ key: 'capture-only', label: 'Capture-only' },
|
|
280
|
+
{ key: 'insight-push', label: 'Insight push' },
|
|
281
|
+
{ key: 'default-on', label: 'Default for all agents' },
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
export function renderMaturation(doc, target, rollout) {
|
|
285
|
+
if (!target) return;
|
|
286
|
+
target.replaceChildren();
|
|
287
|
+
const stage = rollout && typeof rollout.stage === 'string' ? rollout.stage : 'dark';
|
|
288
|
+
const currentIdx = STAGES.findIndex((s) => s.key === stage);
|
|
289
|
+
for (let i = 0; i < STAGES.length; i++) {
|
|
290
|
+
const s = STAGES[i];
|
|
291
|
+
const isHere = i === currentIdx;
|
|
292
|
+
const done = currentIdx >= 0 && i < currentIdx;
|
|
293
|
+
const row = el(doc, 'div', isHere ? 'ph-stage ph-stage-here' : 'ph-stage');
|
|
294
|
+
// glyph + label + marker are ALL renderer-owned static literals
|
|
295
|
+
row.appendChild(el(doc, 'span', 'ph-stage-glyph', done || isHere ? CHROME.stageDone : CHROME.stageFuture));
|
|
296
|
+
row.appendChild(el(doc, 'span', 'ph-stage-label', s.label));
|
|
297
|
+
if (isHere) row.appendChild(el(doc, 'span', 'ph-stage-marker', CHROME.hereMarker));
|
|
298
|
+
else if (done) row.appendChild(el(doc, 'span', 'ph-stage-note', 'done'));
|
|
299
|
+
target.appendChild(row);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Detail drawer body (§4.2e — labeled list items, no tables/JSON/monospace). */
|
|
304
|
+
export function renderDetail(doc, target, analysis) {
|
|
305
|
+
if (!target) return;
|
|
306
|
+
target.replaceChildren();
|
|
307
|
+
if (!analysis) {
|
|
308
|
+
target.appendChild(el(doc, 'p', 'ph-empty', 'No detail yet.'));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const add = (label, val) => target.appendChild(el(doc, 'div', 'ph-detail-row', `${label}: ${val}`));
|
|
312
|
+
add('Total recorded', String(analysis.total ?? 0));
|
|
313
|
+
add('Linked to a known cause', String(analysis.attributed ?? 0));
|
|
314
|
+
add('Not yet linked to a feature', String(analysis.noFeatureLink ?? 0));
|
|
315
|
+
const byCat = analysis.byCategory && typeof analysis.byCategory === 'object' ? analysis.byCategory : {};
|
|
316
|
+
for (const [k, v] of Object.entries(byCat)) {
|
|
317
|
+
add(`Category — ${sanitizeForDisplay(k, 'label')}`, String(v));
|
|
318
|
+
}
|
|
319
|
+
const unknown = analysis.unknownToolchainByAuthor && typeof analysis.unknownToolchainByAuthor === 'object' ? analysis.unknownToolchainByAuthor : {};
|
|
320
|
+
const unknownTotal = Object.values(unknown).reduce((a, b) => a + (Number(b) || 0), 0);
|
|
321
|
+
add('Records with an unknown toolchain', String(unknownTotal));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Polling controller (§4.3 — visibility-gated, abort-safe, coordinated, diff-aware) ──
|
|
325
|
+
|
|
326
|
+
const ENDPOINTS = ['analysis', 'insights', 'failures'];
|
|
327
|
+
const URLS = {
|
|
328
|
+
analysis: '/failures/analysis',
|
|
329
|
+
insights: '/failures/insights?limit=50',
|
|
330
|
+
failures: '/failures?limit=10',
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Factory so tests can inject fetch + clock + DOM. opts:
|
|
335
|
+
* - doc: Document; els: { headline, patterns, captured, maturation, detail, stamp }
|
|
336
|
+
* - fetchImpl: (url, {headers, signal}) => Promise<Response-like>
|
|
337
|
+
* - now: () => ms; cadenceMs (default 60_000); staleMs (default 180_000 = 3×cadence)
|
|
338
|
+
* - schedule/cancel: timer hooks (default setTimeout/clearTimeout)
|
|
339
|
+
*/
|
|
340
|
+
export function createController(opts) {
|
|
341
|
+
const {
|
|
342
|
+
doc,
|
|
343
|
+
els = {},
|
|
344
|
+
fetchImpl,
|
|
345
|
+
now = () => Date.now(),
|
|
346
|
+
cadenceMs = 60_000,
|
|
347
|
+
staleMs = 180_000,
|
|
348
|
+
schedule = (fn, ms) => setTimeout(fn, ms),
|
|
349
|
+
cancel = (id) => clearTimeout(id),
|
|
350
|
+
labelMap = ATTRIBUTION_LABELS,
|
|
351
|
+
} = opts;
|
|
352
|
+
|
|
353
|
+
const state = {
|
|
354
|
+
timerId: null,
|
|
355
|
+
inFlight: null,
|
|
356
|
+
active: false,
|
|
357
|
+
snapshot: { analysis: null, insights: null, failures: null },
|
|
358
|
+
etags: { analysis: null, insights: null, failures: null },
|
|
359
|
+
last200At: { analysis: 0, insights: 0, failures: 0 },
|
|
360
|
+
consecutiveFailedTicks: 0,
|
|
361
|
+
consecutive304Ticks: 0,
|
|
362
|
+
renderedSig: { headline: null, patterns: null, captured: null, maturation: null, detail: null, disabled: false },
|
|
363
|
+
nextDelayMs: cadenceMs,
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
async function fetchOne(key, controller) {
|
|
367
|
+
const headers = {};
|
|
368
|
+
if (state.etags[key]) headers['If-None-Match'] = state.etags[key];
|
|
369
|
+
const resp = await fetchImpl(URLS[key], { headers, signal: controller.signal });
|
|
370
|
+
if (resp.status === 304) return { key, status: 304 };
|
|
371
|
+
if (resp.status === 503) return { key, status: 503 }; // feature OFF — distinct from a transient failure
|
|
372
|
+
if (!resp.ok) throw new Error(`${key} ${resp.status}`);
|
|
373
|
+
const etag = resp.headers && typeof resp.headers.get === 'function' ? resp.headers.get('ETag') : null;
|
|
374
|
+
const body = await resp.json();
|
|
375
|
+
return { key, status: 200, etag, body };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function tick() {
|
|
379
|
+
if (!state.active) return;
|
|
380
|
+
if (state.inFlight) {
|
|
381
|
+
try { state.inFlight.abort(); } catch { /* ignore */ }
|
|
382
|
+
}
|
|
383
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : { signal: undefined, abort() {} };
|
|
384
|
+
state.inFlight = controller;
|
|
385
|
+
let results;
|
|
386
|
+
try {
|
|
387
|
+
results = await Promise.all(ENDPOINTS.map((k) => fetchOne(k, controller)));
|
|
388
|
+
} catch (err) {
|
|
389
|
+
// Any endpoint hard-failed → drop the whole tick, keep prior render.
|
|
390
|
+
if (controller.signal && controller.signal.aborted) return; // superseded; let the new tick own state
|
|
391
|
+
state.inFlight = null;
|
|
392
|
+
state.consecutiveFailedTicks += 1;
|
|
393
|
+
render();
|
|
394
|
+
reschedule();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (controller.signal && controller.signal.aborted) return;
|
|
398
|
+
state.inFlight = null;
|
|
399
|
+
// Feature OFF (503) → pinned disabled copy, not the "connection paused" path.
|
|
400
|
+
if (results.some((r) => r.status === 503)) {
|
|
401
|
+
state.consecutiveFailedTicks = 0;
|
|
402
|
+
state.consecutive304Ticks = 0;
|
|
403
|
+
if (state.renderedSig.disabled !== true) {
|
|
404
|
+
renderDisabled(doc, els);
|
|
405
|
+
state.renderedSig = { headline: null, patterns: null, captured: null, maturation: null, detail: null, disabled: true };
|
|
406
|
+
}
|
|
407
|
+
reschedule();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
state.renderedSig.disabled = false;
|
|
411
|
+
let any200 = false;
|
|
412
|
+
let all304 = true;
|
|
413
|
+
for (const r of results) {
|
|
414
|
+
if (r.status === 200) {
|
|
415
|
+
any200 = true;
|
|
416
|
+
all304 = false;
|
|
417
|
+
state.snapshot[r.key] = r.body;
|
|
418
|
+
if (r.etag) state.etags[r.key] = r.etag;
|
|
419
|
+
state.last200At[r.key] = now();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (any200) {
|
|
423
|
+
state.consecutiveFailedTicks = 0;
|
|
424
|
+
state.consecutive304Ticks = 0;
|
|
425
|
+
} else if (all304) {
|
|
426
|
+
state.consecutive304Ticks += 1;
|
|
427
|
+
}
|
|
428
|
+
render();
|
|
429
|
+
reschedule();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function reschedule() {
|
|
433
|
+
if (!state.active) return;
|
|
434
|
+
state.nextDelayMs = state.consecutive304Ticks >= 5 ? 300_000 : cadenceMs;
|
|
435
|
+
if (state.timerId != null) cancel(state.timerId);
|
|
436
|
+
state.timerId = schedule(() => { void tick(); }, state.nextDelayMs);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Headline staleness keyed on the headline endpoint's OWN last-200 (NEW-3):
|
|
440
|
+
// either 2 hard-fail ticks, OR /failures hasn't produced a fresh 200 within the
|
|
441
|
+
// ceiling — even if siblings keep returning 200/304.
|
|
442
|
+
function headlineStale() {
|
|
443
|
+
if (state.consecutiveFailedTicks >= 2) return true;
|
|
444
|
+
const last = state.last200At.failures;
|
|
445
|
+
if (last === 0) return false; // never loaded yet → not "stale", just initial
|
|
446
|
+
return now() - last > staleMs;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function render() {
|
|
450
|
+
const stale = headlineStale();
|
|
451
|
+
const staleAgeMin = state.last200At.failures ? Math.max(0, Math.round((now() - state.last200At.failures) / 60_000)) : null;
|
|
452
|
+
const failuresBody = state.snapshot.failures;
|
|
453
|
+
const failures = failuresBody && Array.isArray(failuresBody.failures) ? failuresBody.failures : [];
|
|
454
|
+
const insightsBody = state.snapshot.insights;
|
|
455
|
+
const insights = insightsBody && Array.isArray(insightsBody.insights) ? insightsBody.insights : [];
|
|
456
|
+
const analysis = state.snapshot.analysis;
|
|
457
|
+
const rollout = analysis && analysis.rollout ? analysis.rollout : null;
|
|
458
|
+
|
|
459
|
+
// Diff-aware: skip a section's DOM writes when its inputs are unchanged
|
|
460
|
+
// (guarantees 0 mutations on identical ticks; no flicker). Sections rebuild
|
|
461
|
+
// via replaceChildren (NOT innerHTML) only when their signature changes.
|
|
462
|
+
sectionRender('headline', sig(stale, staleAgeMin, failures.length, countOpen(failures)), () =>
|
|
463
|
+
renderHeadline(doc, els.headline, { failures, stale, staleAgeMin }),
|
|
464
|
+
);
|
|
465
|
+
sectionRender('patterns', sig(insights), () => renderPatterns(doc, els.patterns, insights));
|
|
466
|
+
sectionRender('captured', sig(failures), () => renderCaptured(doc, els.captured, failures, labelMap, now()));
|
|
467
|
+
sectionRender('maturation', sig(rollout), () => renderMaturation(doc, els.maturation, rollout));
|
|
468
|
+
sectionRender('detail', sig(analysis), () => renderDetail(doc, els.detail, analysis));
|
|
469
|
+
|
|
470
|
+
if (els.stamp) {
|
|
471
|
+
const ageS = state.last200At.failures ? Math.max(0, Math.round((now() - state.last200At.failures) / 1000)) : null;
|
|
472
|
+
const stampText = ageS == null ? '' : ageS < 60 ? `updated ${ageS}s ago` : `updated ${Math.round(ageS / 60)}m ago`;
|
|
473
|
+
if (els.stamp.textContent !== stampText) els.stamp.textContent = stampText;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function sectionRender(key, signature, fn) {
|
|
478
|
+
if (state.renderedSig[key] === signature) return; // unchanged → 0 mutations
|
|
479
|
+
state.renderedSig[key] = signature;
|
|
480
|
+
fn();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function start() {
|
|
484
|
+
if (state.active) return;
|
|
485
|
+
state.active = true;
|
|
486
|
+
void tick();
|
|
487
|
+
}
|
|
488
|
+
function stop() {
|
|
489
|
+
state.active = false;
|
|
490
|
+
if (state.timerId != null) { cancel(state.timerId); state.timerId = null; }
|
|
491
|
+
if (state.inFlight) { try { state.inFlight.abort(); } catch { /* ignore */ } state.inFlight = null; }
|
|
492
|
+
}
|
|
493
|
+
function onVisible() { if (!state.active) { start(); } }
|
|
494
|
+
function onHidden() { stop(); }
|
|
495
|
+
|
|
496
|
+
return { start, stop, onVisible, onHidden, tick, render, _state: state };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function countOpen(failures) {
|
|
500
|
+
return failures.filter((f) => f && f.status !== 'resolved' && f.status !== 'closed').length;
|
|
501
|
+
}
|
|
502
|
+
function sig(...parts) {
|
|
503
|
+
try { return JSON.stringify(parts); } catch { return String(Math.random()); }
|
|
504
|
+
}
|