scenescout 3.2.0 → 3.4.0
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/CHANGELOG.md +91 -0
- package/README.md +3 -2
- package/dist/cli.js +39 -23
- package/dist/engine/brief.js +134 -0
- package/dist/engine/browser.js +262 -19
- package/dist/engine/calibration.js +217 -0
- package/dist/engine/claims.js +194 -0
- package/dist/engine/lane.js +20 -6
- package/dist/engine/live-page.js +101 -2
- package/dist/engine/live.js +60 -0
- package/dist/engine/memory.js +188 -5
- package/dist/engine/oracles.js +9 -0
- package/dist/engine/pace.js +112 -0
- package/dist/engine/report.js +74 -6
- package/dist/engine/request.js +144 -0
- package/dist/engine/settle.js +97 -0
- package/dist/engine/verify.js +142 -0
- package/dist/mcp-server.js +173 -15
- package/dist/scan.js +47 -1
- package/package.json +10 -3
- package/skills/scenescout/SKILL.md +10 -2
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the page says about itself, checked against what actually happened.
|
|
3
|
+
*
|
|
4
|
+
* Two of the most expensive bugs a web app ships are invisible to every oracle
|
|
5
|
+
* that watches only one side of the wire:
|
|
6
|
+
*
|
|
7
|
+
* - A list request is refused (401, 403, 500) and the page renders its empty
|
|
8
|
+
* state. The user is told they have nothing, when the truth is that nothing
|
|
9
|
+
* could be loaded. Nobody files a bug, because the screen looks fine — it
|
|
10
|
+
* is the single most common way a permission regression reaches production
|
|
11
|
+
* without anyone noticing.
|
|
12
|
+
* - A save is refused and the page says "Saved". The user walks away
|
|
13
|
+
* believing their work is stored.
|
|
14
|
+
*
|
|
15
|
+
* Neither is a crash, so `console_error` and `http_error` miss the harm: the
|
|
16
|
+
* HTTP oracle sees the 403 and reports it as a medium, indistinguishable from
|
|
17
|
+
* the dozens of expected 401s an auth probe produces. The defect is not the
|
|
18
|
+
* refusal. It is the page CONTRADICTING the refusal.
|
|
19
|
+
*
|
|
20
|
+
* The rules pair a precise half with a fuzzy one. The request half is exact —
|
|
21
|
+
* a status code either is an error or is not. The page half only has to be
|
|
22
|
+
* roughly right, because it is never enough on its own: no contradiction is
|
|
23
|
+
* reported unless a request was genuinely refused during the same action. That
|
|
24
|
+
* asymmetry is what keeps the false-positive rate low enough to be worth
|
|
25
|
+
* reporting at high severity.
|
|
26
|
+
*
|
|
27
|
+
* Everything here is pure so it can be table-tested; the DOM read lives in
|
|
28
|
+
* browser.ts.
|
|
29
|
+
*/
|
|
30
|
+
import { VISIBLE_SRC } from "./collector.js";
|
|
31
|
+
/**
|
|
32
|
+
* Methods whose refusal a success message would be lying about. A refused GET
|
|
33
|
+
* is the empty-state rule's business; a refused POST is this one's.
|
|
34
|
+
*/
|
|
35
|
+
const WRITING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
36
|
+
/**
|
|
37
|
+
* Resource types worth correlating. A failed image, font or analytics beacon
|
|
38
|
+
* says nothing about whether the list on screen is real, and correlating them
|
|
39
|
+
* would fire on every page with a broken logo.
|
|
40
|
+
*/
|
|
41
|
+
const DATA_RESOURCES = new Set(["xhr", "fetch", "document"]);
|
|
42
|
+
/** Whether this request is one whose refusal the page should be admitting to. */
|
|
43
|
+
export function isRefused(req) {
|
|
44
|
+
if (req.blockedByPolicy)
|
|
45
|
+
return false;
|
|
46
|
+
if (!DATA_RESOURCES.has(req.resourceType))
|
|
47
|
+
return false;
|
|
48
|
+
if (req.status === null)
|
|
49
|
+
return true;
|
|
50
|
+
return req.status >= 400;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Phrases that assert there is nothing to show.
|
|
54
|
+
*
|
|
55
|
+
* Deliberately narrow: these are the stock empty-state sentences, not any
|
|
56
|
+
* sentence containing "no". "No results", "Nothing to show", "You have no
|
|
57
|
+
* orders" match; "No changes were saved since yesterday" does not, because the
|
|
58
|
+
* assertion has to be about the absence of the things themselves.
|
|
59
|
+
*/
|
|
60
|
+
const EMPTY_RE = /\b(?:no (?:results?|items?|records?|rows?|data|entries|matches)\b|nothing (?:to (?:show|display)|here|found)\b|(?:there are|you have|we found) no\b|(?:0|zero) (?:results?|items?|records?|rows?|matches)\b|no [a-z]{3,20}s (?:found|yet|to show)\b|empty\b)/i;
|
|
61
|
+
/**
|
|
62
|
+
* Phrases that assert something worked. A page that says one of these while
|
|
63
|
+
* the write that produced it was refused is telling the user a falsehood.
|
|
64
|
+
*/
|
|
65
|
+
const SUCCESS_RE = /\b(?:success(?:fully)?|saved|created|updated|deleted|removed|submitted|sent|published|approved|completed|added|changes? saved|done)\b/i;
|
|
66
|
+
/**
|
|
67
|
+
* Phrases that admit something went wrong. Their presence is what makes a page
|
|
68
|
+
* INNOCENT: an app that refuses a request and says so has behaved correctly,
|
|
69
|
+
* whatever else is on the screen, and must not be reported.
|
|
70
|
+
*/
|
|
71
|
+
const ERROR_RE = /\b(?:error|failed|failure|could ?n[o']?t|unable to|went wrong|try again|retry|denied|forbidden|unauthori[sz]ed|not allowed|no permission|timed out|unavailable|problem loading)\b/i;
|
|
72
|
+
/** Longest piece of text judged. An empty state is a sentence; a paragraph that happens to contain one of these words is not a claim. */
|
|
73
|
+
export const CLAIM_TEXT_MAX = 120;
|
|
74
|
+
/**
|
|
75
|
+
* What one piece of visible page text asserts, or nothing when it asserts none
|
|
76
|
+
* of these. An admission of error outranks the others: a banner reading
|
|
77
|
+
* "Couldn't load orders — no results to show" is the app being honest.
|
|
78
|
+
*/
|
|
79
|
+
export function classify(text) {
|
|
80
|
+
const trimmed = text.trim();
|
|
81
|
+
if (!trimmed || trimmed.length > CLAIM_TEXT_MAX)
|
|
82
|
+
return null;
|
|
83
|
+
if (ERROR_RE.test(trimmed))
|
|
84
|
+
return "error";
|
|
85
|
+
if (SUCCESS_RE.test(trimmed))
|
|
86
|
+
return "success";
|
|
87
|
+
if (EMPTY_RE.test(trimmed))
|
|
88
|
+
return "empty";
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
function shortUrl(url) {
|
|
92
|
+
try {
|
|
93
|
+
const u = new URL(url);
|
|
94
|
+
return u.pathname + u.search;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return url.slice(0, 120);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function say(req) {
|
|
101
|
+
return `${req.method} ${shortUrl(req.url)} ${req.status === null ? "failed" : req.status}`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The contradictions this action produced, if any.
|
|
105
|
+
*
|
|
106
|
+
* Both rules are silent whenever the page admits the failure, and both require
|
|
107
|
+
* a genuinely refused request — the page half never fires alone.
|
|
108
|
+
*/
|
|
109
|
+
export function findContradictions(requests, page) {
|
|
110
|
+
const refused = requests.filter(isRefused);
|
|
111
|
+
if (refused.length === 0)
|
|
112
|
+
return [];
|
|
113
|
+
const claims = page.texts.map(classify);
|
|
114
|
+
// An app that says what went wrong has behaved correctly, and nothing below
|
|
115
|
+
// applies. This is checked before anything else so that a page carrying both
|
|
116
|
+
// an error banner and a stale empty state is not reported.
|
|
117
|
+
if (claims.includes("error"))
|
|
118
|
+
return [];
|
|
119
|
+
const out = [];
|
|
120
|
+
const reads = refused.filter((r) => !WRITING_METHODS.has(r.method.toUpperCase()));
|
|
121
|
+
const saysEmpty = claims.includes("empty") || page.emptyLists > 0;
|
|
122
|
+
if (reads.length > 0 && saysEmpty) {
|
|
123
|
+
const worst = reads[0];
|
|
124
|
+
const how = claims.includes("empty") ? "an empty state" : `an empty list (${page.emptyLists})`;
|
|
125
|
+
out.push({
|
|
126
|
+
kind: "refused_empty",
|
|
127
|
+
detail: `${say(worst)} was refused, and the page shows ${how} with no error. ` +
|
|
128
|
+
`The user is told there is nothing to see when the truth is that nothing could be loaded.`,
|
|
129
|
+
evidence: `refused-empty ${say(worst)}`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const writes = refused.filter((r) => WRITING_METHODS.has(r.method.toUpperCase()));
|
|
133
|
+
if (writes.length > 0 && claims.includes("success")) {
|
|
134
|
+
const worst = writes[0];
|
|
135
|
+
const message = page.texts[claims.indexOf("success")];
|
|
136
|
+
out.push({
|
|
137
|
+
kind: "false_success",
|
|
138
|
+
detail: `${say(worst)} was refused, and the page says ${JSON.stringify(message.trim().slice(0, 80))}. The user is told their change was kept when the server rejected it.`,
|
|
139
|
+
evidence: `false-success ${say(worst)}`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/** Most pieces of text read from one page. A page with more than this has nothing useful to say in the extra ones. */
|
|
145
|
+
export const MAX_CLAIM_TEXTS = 120;
|
|
146
|
+
/**
|
|
147
|
+
* Page-side reader for the two signals `findContradictions` needs. Shipped as
|
|
148
|
+
* a STRING expression for the same reason the collector is: loader transforms
|
|
149
|
+
* inject a `__name` helper that does not exist in the browser, and a
|
|
150
|
+
* serialized function carrying a call to it fails there.
|
|
151
|
+
*
|
|
152
|
+
* Text is taken as each element's OWN text nodes, not its subtree, so a
|
|
153
|
+
* sentence is read once rather than again for every ancestor that contains it.
|
|
154
|
+
*
|
|
155
|
+
* The empty-list count is structural on purpose: most apps write no
|
|
156
|
+
* empty-state sentence at all, and the ones that do write it in the user's
|
|
157
|
+
* language. A container that renders its header and no rows says the same
|
|
158
|
+
* thing in every language and in every design system.
|
|
159
|
+
*/
|
|
160
|
+
export const CLAIM_SCAN_SCRIPT = `(() => {
|
|
161
|
+
const visible = ${VISIBLE_SRC};
|
|
162
|
+
const texts = [];
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
|
165
|
+
let el = document.body;
|
|
166
|
+
while (el && texts.length < ${MAX_CLAIM_TEXTS}) {
|
|
167
|
+
let own = "";
|
|
168
|
+
for (const node of el.childNodes) if (node.nodeType === 3) own += node.nodeValue;
|
|
169
|
+
own = own.replace(/\\s+/g, " ").trim();
|
|
170
|
+
if (own && own.length <= ${CLAIM_TEXT_MAX} && !seen.has(own) && visible(el)) {
|
|
171
|
+
seen.add(own);
|
|
172
|
+
texts.push(own);
|
|
173
|
+
}
|
|
174
|
+
el = walker.nextNode();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A list or table that renders its container but no rows. Hidden ones are
|
|
178
|
+
// templates and dropdown shells, not empty states.
|
|
179
|
+
let emptyLists = 0;
|
|
180
|
+
for (const list of document.querySelectorAll("table, ul, ol, [role='table'], [role='grid'], [role='list']")) {
|
|
181
|
+
if (!visible(list)) continue;
|
|
182
|
+
const tag = list.tagName.toLowerCase();
|
|
183
|
+
if (tag === "table") {
|
|
184
|
+
const body = list.tBodies[0];
|
|
185
|
+
// No header means it is a layout table, not a record list.
|
|
186
|
+
if (!list.querySelector("th, thead")) continue;
|
|
187
|
+
if (body && body.rows.length === 0) emptyLists += 1;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const rows = list.querySelectorAll(":scope > li, :scope > [role='row'], :scope > [role='listitem']");
|
|
191
|
+
if (rows.length === 0) emptyLists += 1;
|
|
192
|
+
}
|
|
193
|
+
return { texts, emptyLists };
|
|
194
|
+
})()`;
|
package/dist/engine/lane.js
CHANGED
|
@@ -138,14 +138,28 @@ export function parseLaneReport(text, expectedLane) {
|
|
|
138
138
|
*/
|
|
139
139
|
export function laneReportInstruction(lane) {
|
|
140
140
|
return [
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
...LANE_RUBRIC,
|
|
142
|
+
// The ONLY lane-specific sentence, and it comes last on purpose. Every
|
|
143
|
+
// lane in a wave is given the same rubric, so keeping it byte-identical up
|
|
144
|
+
// to here makes it one shared prompt prefix: the cache hits from the
|
|
145
|
+
// second lane onward instead of diverging at the first sentence, which is
|
|
146
|
+
// what putting the name in the shape line used to do.
|
|
147
|
+
`Your lane name is ${JSON.stringify(lane)}; put exactly that in "lane".`,
|
|
147
148
|
].join(" ");
|
|
148
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Everything every lane is told, identical for all of them. Built once from
|
|
152
|
+
* the same constants the parser enforces, because a limit a lane is not told
|
|
153
|
+
* refuses good replies.
|
|
154
|
+
*/
|
|
155
|
+
const LANE_RUBRIC = [
|
|
156
|
+
"Reply with ONE JSON object and nothing else — no prose before or after it, no explanation, no headings. A fenced ```json block is fine.",
|
|
157
|
+
`Shape: {"lane":<your lane name>,"status":<${quoteAll(LANE_STATUSES)}>,"decisions":[…],"routes":[…],"blocked_by":<string or null>}.`,
|
|
158
|
+
`Each decision: {"observation":<a short id for what was observed, unique in the report, at most ${LANE_OBSERVATION_MAX} characters>,"verdict":<${quoteAll(LANE_VERDICTS)}>,"severity":<${quoteAll(LANE_SEVERITIES)} or null>,"category":<${quoteAll(LANE_CATEGORIES)} or null>,"confidence":<0..1>,"evidence":<machine signature such as "GET /api/things 500", or null>}.`,
|
|
159
|
+
`A "defect" must carry a severity and a category. "evidence" is a signature, not a sentence: at most ${LANE_EVIDENCE_MAX} characters. "confidence" is how sure you are of the verdict, calibrated: 0.5 means a coin flip, 0.95 means you would bet on it.`,
|
|
160
|
+
`"routes" lists the routes you covered, each at most ${LANE_ROUTE_MAX} characters. "blocked_by" is one line of at most ${LANE_BLOCKED_BY_MAX} characters saying what stopped you: required when the status is "blocked", allowed with "partial", null with "complete"; the detail belongs in a finding. The lane name is at most ${LANE_NAME_MAX} characters. At most ${LANE_MAX_ITEMS} decisions and ${LANE_MAX_ITEMS} routes. Unknown keys are refused.`,
|
|
161
|
+
"The object IS your final report: whatever hands it back must hand back the object verbatim, not a summary of it.",
|
|
162
|
+
];
|
|
149
163
|
function quoteAll(values) {
|
|
150
164
|
return values.map((v) => `"${v}"`).join("|");
|
|
151
165
|
}
|
package/dist/engine/live-page.js
CHANGED
|
@@ -58,6 +58,10 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
58
58
|
color: #fff; background: var(--accent); border-radius: 6px; }
|
|
59
59
|
main { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: 12px; padding: 16px; }
|
|
60
60
|
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; }
|
|
61
|
+
/* Every rule here that sets display beats the browser's own [hidden] rule,
|
|
62
|
+
so a hidden card stayed on screen with the "nothing matches" notice above
|
|
63
|
+
it. Say it once, for everything. */
|
|
64
|
+
[hidden] { display: none !important; }
|
|
61
65
|
.card.stuck { border-color: var(--stuck); }
|
|
62
66
|
.top { display: flex; align-items: center; gap: 8px; padding: 10px 12px 6px; }
|
|
63
67
|
.name { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
@@ -118,6 +122,10 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
118
122
|
.doing { padding: 0 12px 4px; font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
119
123
|
.doing::before { content: "▸ "; color: var(--accent); }
|
|
120
124
|
.doing.unset { color: var(--stuck); }
|
|
125
|
+
.pace { padding: 0 12px 4px; font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
126
|
+
.pace .bad { color: var(--stuck); }
|
|
127
|
+
header input#filter { font: inherit; font-size: 12px; padding: 4px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--text); min-width: 150px; }
|
|
128
|
+
#no-match { padding: 16px; color: var(--muted); }
|
|
121
129
|
#focus .feed .a { color: #e6e9ee; }
|
|
122
130
|
#focus .feed .t, #focus .feed .d, #focus .feed .none { color: #98a2b3; }
|
|
123
131
|
#focus .feed .bad { color: #fca5a5; }
|
|
@@ -188,12 +196,14 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
188
196
|
<span class="spacer"></span>
|
|
189
197
|
<span class="meta" id="counts" data-testid="live-session-counts"></span>
|
|
190
198
|
<span class="actions">
|
|
199
|
+
<input type="search" id="filter" placeholder="Filter sessions" aria-label="Filter sessions by name, role, objective, task or page" data-testid="live-filter" />
|
|
191
200
|
<button type="button" id="report-open" data-testid="live-report-toggle">Report</button>
|
|
192
201
|
<button type="button" id="all" aria-pressed="false" data-testid="live-all-toggle">Stream all</button>
|
|
193
202
|
</span>
|
|
194
203
|
</header>
|
|
195
204
|
<div id="banner" role="alert" data-testid="live-unreachable-banner">The engine is not answering. It may have exited; this page will pick up again if it comes back.</div>
|
|
196
205
|
<div id="empty" data-testid="live-empty-state">No session is attached yet. Cards appear here as soon as one attaches.</div>
|
|
206
|
+
<div id="no-match" data-testid="live-no-match" hidden></div>
|
|
197
207
|
<div id="finished" data-testid="live-finished-state">
|
|
198
208
|
<h2>The run has finished</h2>
|
|
199
209
|
<p>Its browsers are closed, so there is nothing left to watch. What it found is in the report.</p>
|
|
@@ -240,6 +250,8 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
240
250
|
var THUMB_EVERY_MS = 3000;
|
|
241
251
|
var cards = {};
|
|
242
252
|
var streamAll = false;
|
|
253
|
+
/** The header filter, lower-cased. Hides cards; never stops a session running. */
|
|
254
|
+
var filter = '';
|
|
243
255
|
var focused = null;
|
|
244
256
|
var skew = 0;
|
|
245
257
|
var latest = {};
|
|
@@ -364,6 +376,14 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
364
376
|
task.setAttribute('data-testid', 'live-card-objective-' + name);
|
|
365
377
|
var doing = el('div', 'doing');
|
|
366
378
|
doing.setAttribute('data-testid', 'live-card-task-' + name);
|
|
379
|
+
// How long it has been on this task, and how many of its recent steps went
|
|
380
|
+
// wrong. Both were already in the status payload and reached nobody: the
|
|
381
|
+
// first only inside the close-up, the second only as a red word in a feed
|
|
382
|
+
// somebody had to read. On a board of eleven cards, "which one is stuck on
|
|
383
|
+
// the same thing, and which one is having trouble" is the question being
|
|
384
|
+
// asked, and it was the one thing the board could not answer.
|
|
385
|
+
var pace = el('div', 'pace');
|
|
386
|
+
pace.setAttribute('data-testid', 'live-card-pace-' + name);
|
|
367
387
|
var tool = el('div', 'line');
|
|
368
388
|
var url = el('div', 'line');
|
|
369
389
|
var shot = el('button', 'shot');
|
|
@@ -386,9 +406,9 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
386
406
|
toggle.setAttribute('data-testid', 'live-card-toggle-' + name);
|
|
387
407
|
var spec = el('span', 'spec');
|
|
388
408
|
foot.appendChild(toggle); foot.appendChild(spec);
|
|
389
|
-
root.appendChild(top); root.appendChild(task); root.appendChild(doing); root.appendChild(tool); root.appendChild(url); root.appendChild(shot); root.appendChild(feed); root.appendChild(foot);
|
|
409
|
+
root.appendChild(top); root.appendChild(task); root.appendChild(doing); root.appendChild(pace); root.appendChild(tool); root.appendChild(url); root.appendChild(shot); root.appendChild(feed); root.appendChild(foot);
|
|
390
410
|
|
|
391
|
-
var card = { name: name, root: root, role: role, badge: badge, task: task, doing: doing, tool: tool, url: url, shot: shot, img: img, feed: feed, toggle: toggle, spec: spec, live: false };
|
|
411
|
+
var card = { name: name, root: root, role: role, badge: badge, task: task, doing: doing, pace: pace, tool: tool, url: url, shot: shot, img: img, feed: feed, toggle: toggle, spec: spec, live: false };
|
|
392
412
|
toggle.addEventListener('click', function () { setLive(card, !card.live); });
|
|
393
413
|
shot.addEventListener('click', function () { openFocus(name); });
|
|
394
414
|
img.src = shotUrl(name);
|
|
@@ -735,9 +755,31 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
735
755
|
card.url.textContent = s.url || '(no page yet)';
|
|
736
756
|
card.url.title = s.url || '';
|
|
737
757
|
card.spec.textContent = [s.mode, s.browser, s.headed ? 'headed' : 'headless'].filter(Boolean).join(' · ');
|
|
758
|
+
paintPace(card, s);
|
|
738
759
|
renderFeed(card.feed, s.feed);
|
|
739
760
|
}
|
|
740
761
|
|
|
762
|
+
/** Steps in the visible feed whose result reads as trouble. */
|
|
763
|
+
function troubled(feed) {
|
|
764
|
+
var n = 0;
|
|
765
|
+
(feed || []).forEach(function (line) { if (BAD_RESULT.test(line.result || '')) n += 1; });
|
|
766
|
+
return n;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function paintPace(card, s) {
|
|
770
|
+
var on = s.task && s.taskSince ? 'on this for ' + held(Date.now() + skew - Date.parse(s.taskSince)) : '';
|
|
771
|
+
var bad = troubled(s.feed);
|
|
772
|
+
card.pace.textContent = '';
|
|
773
|
+
if (on) card.pace.appendChild(el('span', '', on));
|
|
774
|
+
if (bad > 0) {
|
|
775
|
+
if (on) card.pace.appendChild(el('span', '', ' · '));
|
|
776
|
+
// Only the trouble is red. Reddening the whole line made "on this for
|
|
777
|
+
// 9s" look like the complaint.
|
|
778
|
+
card.pace.appendChild(el('span', 'bad', bad + ' of the last ' + s.feed.length + ' steps went wrong'));
|
|
779
|
+
}
|
|
780
|
+
card.pace.hidden = !on && bad === 0;
|
|
781
|
+
}
|
|
782
|
+
|
|
741
783
|
function openFocus(name) {
|
|
742
784
|
focused = name;
|
|
743
785
|
scrubbed = null;
|
|
@@ -827,6 +869,32 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
827
869
|
focusTick += 1;
|
|
828
870
|
}
|
|
829
871
|
|
|
872
|
+
/**
|
|
873
|
+
* Whether a session survives the header's filter. Matched against everything
|
|
874
|
+
* the card already shows, because on a board of eleven the reader is looking
|
|
875
|
+
* for "the one on the orders register" as often as for a session by name.
|
|
876
|
+
* Filtering hides cards; it never stops them being polled or streamed, so a
|
|
877
|
+
* hidden session is still running and still counted in the header.
|
|
878
|
+
*/
|
|
879
|
+
function matches(s) {
|
|
880
|
+
if (!filter) return true;
|
|
881
|
+
var hay = [s.session, s.role, s.objective, s.task, s.url, s.tool].join(' ').toLowerCase();
|
|
882
|
+
return hay.indexOf(filter) !== -1;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function applyFilter() {
|
|
886
|
+
var shown = 0;
|
|
887
|
+
Object.keys(cards).forEach(function (name) {
|
|
888
|
+
var s = latest[name];
|
|
889
|
+
var keep = !s || matches(s);
|
|
890
|
+
cards[name].root.hidden = !keep;
|
|
891
|
+
if (keep) shown += 1;
|
|
892
|
+
});
|
|
893
|
+
var none = document.getElementById('no-match');
|
|
894
|
+
none.hidden = !filter || shown > 0 || Object.keys(cards).length === 0;
|
|
895
|
+
none.textContent = 'No session matches ' + JSON.stringify(filter) + '.';
|
|
896
|
+
}
|
|
897
|
+
|
|
830
898
|
function apply(snap) {
|
|
831
899
|
skew = Date.parse(snap.at) - Date.now();
|
|
832
900
|
var grid = document.getElementById('grid');
|
|
@@ -884,6 +952,7 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
884
952
|
document.getElementById('engine').textContent = 'engine pid ' + snap.pid + ' · v' + snap.version;
|
|
885
953
|
document.getElementById('counts').textContent = snap.sessions.length + ' session' + (snap.sessions.length === 1 ? '' : 's') +
|
|
886
954
|
' · ' + counts.running + ' running · ' + counts.idle + ' idle' + (counts.stuck ? ' · ' + counts.stuck + ' stuck' : '');
|
|
955
|
+
applyFilter();
|
|
887
956
|
paintFocus();
|
|
888
957
|
}
|
|
889
958
|
|
|
@@ -927,7 +996,37 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
927
996
|
e.returnValue = '';
|
|
928
997
|
});
|
|
929
998
|
document.getElementById('report-close').addEventListener('click', closeReport);
|
|
999
|
+
document.getElementById('filter').addEventListener('input', function (e) {
|
|
1000
|
+
filter = String(e.target.value || '').trim().toLowerCase();
|
|
1001
|
+
applyFilter();
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Step through the timeline from the keyboard.
|
|
1006
|
+
*
|
|
1007
|
+
* Scrubbing a long run by clicking 16px ticks is the kind of thing a mouse
|
|
1008
|
+
* is bad at, and the run being examined is usually the one with hundreds of
|
|
1009
|
+
* steps. Arrow keys move one step, Home and End jump to the ends, and Space
|
|
1010
|
+
* returns to the live picture — only while the close-up is open, and never
|
|
1011
|
+
* while the reader is typing in the filter.
|
|
1012
|
+
*/
|
|
1013
|
+
function stepBy(delta) {
|
|
1014
|
+
if (timelineLines.length === 0) return;
|
|
1015
|
+
var at = scrubbed
|
|
1016
|
+
? timelineLines.findIndex(function (l) { return l.at === scrubbed.at && l.action === scrubbed.action; })
|
|
1017
|
+
: timelineLines.length - 1;
|
|
1018
|
+
var next = Math.max(0, Math.min(timelineLines.length - 1, (at === -1 ? timelineLines.length - 1 : at) + delta));
|
|
1019
|
+
showStep(timelineLines[next]);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
930
1022
|
document.addEventListener('keydown', function (e) {
|
|
1023
|
+
if (focused && !reportOpen && e.target !== document.getElementById('filter')) {
|
|
1024
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); stepBy(e.key === 'ArrowLeft' ? -1 : 1); return; }
|
|
1025
|
+
if (e.key === 'Home' || e.key === 'End') { e.preventDefault(); stepBy(e.key === 'Home' ? -1e9 : 1e9); return; }
|
|
1026
|
+
// Back to what the session is showing NOW, which is otherwise a click
|
|
1027
|
+
// on a button the reader has to find.
|
|
1028
|
+
if (e.key === ' ' && scrubbed) { e.preventDefault(); backToLive(); return; }
|
|
1029
|
+
}
|
|
931
1030
|
if (e.key !== 'Escape') return;
|
|
932
1031
|
if (reportOpen) closeReport();
|
|
933
1032
|
else if (focused) closeFocus();
|
package/dist/engine/live.js
CHANGED
|
@@ -30,6 +30,10 @@ import { LIVE_PAGE } from "./live-page.js";
|
|
|
30
30
|
import { JOURNEY_END, JOURNEY_START, TASK_SET } from "./memory.js";
|
|
31
31
|
/** Holds the live view's token, next to status.json. Written owner-only; removed when the engine shuts down. */
|
|
32
32
|
export const LIVE_TOKEN_FILE = "live-token";
|
|
33
|
+
/** This engine's own token file. Shares the directory with other engines, so it carries the pid. */
|
|
34
|
+
export function liveTokenFileName(pid) {
|
|
35
|
+
return `${LIVE_TOKEN_FILE}.${pid}`;
|
|
36
|
+
}
|
|
33
37
|
/** `SCENESCOUT_LIVE=off` keeps the engine from opening the live view's port at all. */
|
|
34
38
|
export const LIVE_ENV = "SCENESCOUT_LIVE";
|
|
35
39
|
/**
|
|
@@ -178,12 +182,68 @@ const statusWrites = new Map();
|
|
|
178
182
|
* longer one after it (which is what overlapping `writeFile`s produced).
|
|
179
183
|
* Best-effort: a failed write is dropped and the next one lands.
|
|
180
184
|
*/
|
|
185
|
+
/**
|
|
186
|
+
* Whether a process is still there. EPERM means it EXISTS but belongs to
|
|
187
|
+
* another user; only ESRCH means no such process, and treating both as dead
|
|
188
|
+
* reported a live engine as stale.
|
|
189
|
+
*/
|
|
190
|
+
export function pidAlive(pid) {
|
|
191
|
+
if (!pid)
|
|
192
|
+
return false;
|
|
193
|
+
try {
|
|
194
|
+
process.kill(pid, 0);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
return err?.code === "EPERM";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export function statusFileName(pid) {
|
|
202
|
+
return `status.${pid}.json`;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Every engine that has written a status file here, newest first, one entry
|
|
206
|
+
* per pid. Two engines on one project used to overwrite each other in a single
|
|
207
|
+
* status.json, so `watch` could only ever find whichever attached last and the
|
|
208
|
+
* other run was reachable only from the transcript that started it.
|
|
209
|
+
* status.json is still written for readers from before this, and is used only
|
|
210
|
+
* when no per-pid file exists.
|
|
211
|
+
*/
|
|
212
|
+
export function liveEngines(dir, isAlive) {
|
|
213
|
+
let names = [];
|
|
214
|
+
try {
|
|
215
|
+
names = fs.readdirSync(dir);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
const perPid = names.filter((n) => /^status\.\d+\.json$/.test(n));
|
|
221
|
+
const out = [];
|
|
222
|
+
for (const name of perPid.length > 0 ? perPid : names.filter((n) => n === "status.json")) {
|
|
223
|
+
let parsed;
|
|
224
|
+
try {
|
|
225
|
+
parsed = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const pid = typeof parsed.pid === "number" ? parsed.pid : NaN;
|
|
231
|
+
if (!Number.isInteger(pid) || !isAlive(pid))
|
|
232
|
+
continue;
|
|
233
|
+
out.push({ pid, status: parsed, file: name });
|
|
234
|
+
}
|
|
235
|
+
return out.sort((a, b) => (b.status.at ?? "").localeCompare(a.status.at ?? ""));
|
|
236
|
+
}
|
|
181
237
|
export function writeStatusFile(dir, body) {
|
|
182
238
|
const file = path.join(dir, "status.json");
|
|
183
239
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
240
|
+
// Both: status.json for readers from before per-pid files, and one named for
|
|
241
|
+
// this process so a second engine on the same project does not erase it.
|
|
242
|
+
const mine = path.join(dir, statusFileName(process.pid));
|
|
184
243
|
const next = (statusWrites.get(dir) ?? Promise.resolve())
|
|
185
244
|
.then(() => fs.promises.writeFile(tmp, body))
|
|
186
245
|
.then(() => fs.promises.rename(tmp, file))
|
|
246
|
+
.then(() => fs.promises.writeFile(mine, body))
|
|
187
247
|
.catch(() => fs.promises.rm(tmp, { force: true }).catch(() => { }));
|
|
188
248
|
statusWrites.set(dir, next);
|
|
189
249
|
return next;
|