ccdeck 3.29.11 → 3.30.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/README.md +1 -1
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +22 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +2 -2
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +51 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +172 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +93 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -27,17 +27,29 @@
|
|
|
27
27
|
// is running, so a deck that was closed all weekend still answers Monday's
|
|
28
28
|
// question completely — which is why there is no process to keep alive and no
|
|
29
29
|
// gap to apologise for.
|
|
30
|
-
import { statSync } from "node:fs";
|
|
31
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
32
|
-
import { join } from "node:path";
|
|
33
|
-
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
34
30
|
import { discoverProfiles } from "./browser-profiles.mjs";
|
|
35
31
|
import { msToChromeTime, readVisitsSince } from "./browser-history.mjs";
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
32
|
+
import { toEpisodes, defaultExclusions } from "./agent-activity.mjs";
|
|
33
|
+
import { episodeKey, mergeEpisodes, readStore, undismissed, updateStore, writeStore } from "./browser-watch-store.mjs";
|
|
34
|
+
// watch.log, the plain-text record a person reads without opening the panel.
|
|
35
|
+
import { appendLog, logPath, logSize } from "./browser-watch-log.mjs";
|
|
38
36
|
import { browserSurvey } from "./browser-presence.mjs";
|
|
39
37
|
import { available, performable, react } from "./browser-react.mjs";
|
|
40
|
-
import { RELAY_HOST
|
|
38
|
+
import { RELAY_HOST } from "./relay-guard.mjs";
|
|
39
|
+
// The stamp the History cache below is keyed on, shared with the relay report.
|
|
40
|
+
import { mtimeMs } from "./browser-watch-mtime.mjs";
|
|
41
|
+
// What relay-guard can say about this machine, and the two reads it needs.
|
|
42
|
+
import { relayGuard } from "./browser-watch-relay.mjs";
|
|
43
|
+
// Which decks are here: whose tabs are the deck's own, and which one writes.
|
|
44
|
+
import { isReactingDeck } from "./browser-watch-decks.mjs";
|
|
45
|
+
export { deckOwnOrigins, registeredDeckPorts } from "./browser-watch-decks.mjs";
|
|
46
|
+
// The feed: what the watch did, one line at a time, newest first.
|
|
47
|
+
import { note, watchLog } from "./browser-watch-feed.mjs";
|
|
48
|
+
export { noteWatchSetting } from "./browser-watch-feed.mjs";
|
|
49
|
+
// What each profile has contributed since the deck started, and the fold that
|
|
50
|
+
// adds one read to it.
|
|
51
|
+
import { absorb, nothingSeen } from "./browser-watch-seen.mjs";
|
|
52
|
+
export { absorb, nothingSeen } from "./browser-watch-seen.mjs";
|
|
41
53
|
|
|
42
54
|
/**
|
|
43
55
|
* The moment this deck started, and where every profile's first read begins.
|
|
@@ -89,114 +101,6 @@ const _floor = new Map();
|
|
|
89
101
|
* survive between them. */
|
|
90
102
|
const cache = new Map();
|
|
91
103
|
|
|
92
|
-
/** The file's modification time in ms, or null when it is not there at all —
|
|
93
|
-
* an uninstalled browser, a profile that has never been opened, a home
|
|
94
|
-
* directory on a volume that is not mounted. Never throws: one unreadable
|
|
95
|
-
* profile must not take the other browsers' answers down with it. */
|
|
96
|
-
function mtimeMs(file, deps) {
|
|
97
|
-
try { return (deps.statSync ?? statSync)(file).mtimeMs; } catch { return null; }
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Secure Preferences reports, keyed on the file and its mtime — see
|
|
101
|
-
* `relayGuard` for why this one needs a cache more than the History read
|
|
102
|
-
* does. */
|
|
103
|
-
const extCache = new Map();
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* What relay-guard can say about this machine, from two reads it does not do
|
|
107
|
-
* itself.
|
|
108
|
-
*
|
|
109
|
-
* THE MODULE WAS BUILT AND NEVER PLUGGED IN (#799). Every export but
|
|
110
|
-
* `RELAY_HOST` greped to its own declaration and its test, so the header's
|
|
111
|
-
* promise — "the one command that closes it … hands back the command that would
|
|
112
|
-
* change it, as text, for the user to paste" — reached no surface. A reader
|
|
113
|
-
* auditing this repo's security posture would have believed the killswitch and
|
|
114
|
-
* the grant report ship. This is that promise kept: the panel now renders both.
|
|
115
|
-
*
|
|
116
|
-
* relay-guard imports node:path and nothing else, on purpose, so the reading is
|
|
117
|
-
* here. Two sources:
|
|
118
|
-
*
|
|
119
|
-
* THE HOSTS FILE, once. Small, and the same file for every profile.
|
|
120
|
-
*
|
|
121
|
-
* EACH PROFILE'S "Secure Preferences", only where `hasExtension` already said
|
|
122
|
-
* the directory is there. This one is why there is a cache: it is a single
|
|
123
|
-
* JSON document holding every extension's settings and it runs to megabytes
|
|
124
|
-
* on a profile with a few installed, while the panel polls every ten seconds.
|
|
125
|
-
* Keyed on mtime like the History cache above, and for the same reason — a
|
|
126
|
-
* browser that is closed cannot invalidate it.
|
|
127
|
-
*
|
|
128
|
-
* A read that fails is not a report of "nothing installed": `null` for that
|
|
129
|
-
* profile, and the aggregate says so. The difference matters here more than
|
|
130
|
-
* anywhere else in this file, because the reassuring answer and the unreadable
|
|
131
|
-
* answer are the same shape.
|
|
132
|
-
*/
|
|
133
|
-
async function relayGuard(profiles, { platform, env, deps }) {
|
|
134
|
-
const readOne = async (file) => {
|
|
135
|
-
const stamp = mtimeMs(file, deps);
|
|
136
|
-
if (stamp === null) return null;
|
|
137
|
-
const hit = extCache.get(file);
|
|
138
|
-
if (hit && hit.stamp === stamp) return hit.report;
|
|
139
|
-
let report = null;
|
|
140
|
-
try {
|
|
141
|
-
report = extensionReport(JSON.parse(await (deps.readFile ?? readFile)(file, "utf8")));
|
|
142
|
-
} catch {
|
|
143
|
-
// Unreadable or not JSON — a profile being written as we looked, a
|
|
144
|
-
// hardened profile we cannot open. Not cached, so the next poll retries.
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
extCache.set(file, { stamp, report });
|
|
148
|
-
return report;
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
const seen = [];
|
|
152
|
-
for (const profile of profiles) {
|
|
153
|
-
// `hasClaudeExt` is an existsSync on `Extensions/<id>` and is already
|
|
154
|
-
// computed; it cannot see `enabled`, `allUrls` or `sensitiveApis`, which is
|
|
155
|
-
// the whole reason this reads the preferences at all. But it is a free way
|
|
156
|
-
// to skip every profile that has no extension to report on.
|
|
157
|
-
if (!profile.hasClaudeExt) continue;
|
|
158
|
-
const report = await readOne(profile.securePrefsPath);
|
|
159
|
-
seen.push({
|
|
160
|
-
browser: profile.browser,
|
|
161
|
-
name: profile.name,
|
|
162
|
-
profile: profile.profile,
|
|
163
|
-
// Null when the file could not be read, which the panel says out loud
|
|
164
|
-
// rather than rendering as an absence of permissions.
|
|
165
|
-
report,
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
let hostsText = null;
|
|
170
|
-
const hosts = (deps.hostsPath ?? hostsPath)(platform, env);
|
|
171
|
-
try { hostsText = await (deps.readFile ?? readFile)(hosts, "utf8"); } catch { /* no file, or no permission to read it */ }
|
|
172
|
-
const killswitch = readKillswitch(hostsText);
|
|
173
|
-
|
|
174
|
-
// ONE ENABLED COPY ANYWHERE IS ENOUGH, which is `verdict`'s own rule: the
|
|
175
|
-
// relay is registered per ANTHROPIC ACCOUNT, not per profile, so a second
|
|
176
|
-
// profile with the extension switched off protects nothing.
|
|
177
|
-
const anyExtension = seen.some(p => p.report?.present === true && p.report.enabled === true);
|
|
178
|
-
|
|
179
|
-
return {
|
|
180
|
-
relayHost: RELAY_HOST,
|
|
181
|
-
hostsPath: hosts,
|
|
182
|
-
// Whether the hosts file could be read at all. `blocked: false` from an
|
|
183
|
-
// unreadable file and `blocked: false` from a file with no entry are the
|
|
184
|
-
// same value and not the same fact.
|
|
185
|
-
hostsRead: typeof hostsText === "string",
|
|
186
|
-
profiles: seen,
|
|
187
|
-
anyExtension,
|
|
188
|
-
killswitch,
|
|
189
|
-
verdict: verdict({ anyExtension, blocked: killswitch.blocked }),
|
|
190
|
-
// Both, always, so the panel can offer the one that matches the state
|
|
191
|
-
// without having to know how either is spelled. Text only — nothing here
|
|
192
|
-
// runs it, and relay-guard could not if it tried.
|
|
193
|
-
command: {
|
|
194
|
-
block: killswitchCommand(platform, { on: true }),
|
|
195
|
-
unblock: killswitchCommand(platform, { on: false }),
|
|
196
|
-
},
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
|
|
200
104
|
/**
|
|
201
105
|
* Visits for one profile, re-reading only when the browser has written since
|
|
202
106
|
* the last look.
|
|
@@ -206,22 +110,6 @@ async function relayGuard(profiles, { platform, env, deps }) {
|
|
|
206
110
|
* schedule. `stale` is reported so the panel can say when it last actually
|
|
207
111
|
* looked rather than implying the answer is a live one.
|
|
208
112
|
*/
|
|
209
|
-
/**
|
|
210
|
-
* Is that pid still running?
|
|
211
|
-
*
|
|
212
|
-
* A bare `catch { continue; }` used to stand where this is called, which threw
|
|
213
|
-
* away the one distinction that matters: a process this account may not signal
|
|
214
|
-
* answers EPERM on POSIX and EACCES on Windows (libuv maps
|
|
215
|
-
* ERROR_ACCESS_DENIED), and both mean ALIVE. Treating them as gone made an
|
|
216
|
-
* elevated deck invisible to the writer election below, which is how a machine
|
|
217
|
-
* ends up with two elected writers — duplicate log lines, duplicate reactions,
|
|
218
|
-
* and two writers racing the same rename.
|
|
219
|
-
*/
|
|
220
|
-
function pidAlive(pid) {
|
|
221
|
-
try { process.kill(pid, 0); return true; }
|
|
222
|
-
catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
|
|
223
|
-
}
|
|
224
|
-
|
|
225
113
|
async function visitsFor(profile, { sinceChromeTime, countSince, copyDir, deps = {} }) {
|
|
226
114
|
const stamp = mtimeMs(profile.historyPath, deps);
|
|
227
115
|
if (stamp === null) return { rows: [], degraded: true, reason: "no-history-file", stamp: null };
|
|
@@ -274,7 +162,7 @@ export function invalidateBrowserWatchCache() {
|
|
|
274
162
|
}
|
|
275
163
|
|
|
276
164
|
/** The floor between two reads somebody paid for, spelled the way quota.mjs,
|
|
277
|
-
* codex-quota.mjs, codex-usage.mjs,
|
|
165
|
+
* codex-quota.mjs, codex-usage.mjs, npm-latest.mjs and claude-accounts.mjs
|
|
278
166
|
* spell it — one idea, one name, one number. */
|
|
279
167
|
const FORCE_POLL_MS = 60_000;
|
|
280
168
|
|
|
@@ -373,184 +261,6 @@ function occupy(read) {
|
|
|
373
261
|
return held;
|
|
374
262
|
}
|
|
375
263
|
|
|
376
|
-
/**
|
|
377
|
-
* Every loopback address a ccdeck could have opened a tab on.
|
|
378
|
-
*
|
|
379
|
-
* Not just this process's port. The deck asks for 4317 and, when something else
|
|
380
|
-
* already holds it, binds a RANDOM port in 4318-4400 instead (startServer's
|
|
381
|
-
* `portRange`), so a machine that has been running decks for a month has tabs
|
|
382
|
-
* on several. The real profile this feature was tuned against carried 41 visits
|
|
383
|
-
* to 127.0.0.1:4317 and 34 to 127.0.0.1:4399 — two ports, both this deck, both
|
|
384
|
-
* FROM_API because `open` is an API call, and every one of them a card the
|
|
385
|
-
* panel would have shown its owner about itself.
|
|
386
|
-
*
|
|
387
|
-
* The whole range rather than the ports seen: the alternative is to remember
|
|
388
|
-
* which ports past decks used, which is a file to keep, a file to migrate, and
|
|
389
|
-
* a file that is empty the first time it matters. Eighty-four loopback ports
|
|
390
|
-
* this program documents as its own are not a meaningful loss of coverage — a
|
|
391
|
-
* user's own dev server on 3000 or 44440 is still reported, which is the case
|
|
392
|
-
* that would have hurt.
|
|
393
|
-
*/
|
|
394
|
-
export function deckOwnOrigins(portRange = [4317, 4400], registered = []) {
|
|
395
|
-
const [lo, hi] = portRange;
|
|
396
|
-
const out = [];
|
|
397
|
-
for (let port = lo; port <= hi; port++) out.push(`http://127.0.0.1:${port}`);
|
|
398
|
-
// AND THE PORTS DECKS ACTUALLY REGISTERED, which the range cannot know about.
|
|
399
|
-
// The range covers the default and its fallback; an explicit `--port` lands
|
|
400
|
-
// anywhere. Measured: a deck running from a worktree on `--port 4793` opened
|
|
401
|
-
// its own tab, and this panel reported it to its owner as a program driving
|
|
402
|
-
// the browser — which it was, and the program was ccdeck.
|
|
403
|
-
//
|
|
404
|
-
// Read rather than guessed. The registry already holds a port per live deck
|
|
405
|
-
// for the election, so this is a fact the machine has, not a range somebody
|
|
406
|
-
// has to keep current.
|
|
407
|
-
//
|
|
408
|
-
// A deck that registered NOTHING is still reported, and that is right rather
|
|
409
|
-
// than a gap: from here it is a program driving the browser and nothing
|
|
410
|
-
// announces otherwise. The reader can dismiss it once and it stays dismissed.
|
|
411
|
-
for (const port of registered) {
|
|
412
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) continue;
|
|
413
|
-
if (port >= lo && port <= hi) continue;
|
|
414
|
-
out.push(`http://127.0.0.1:${port}`);
|
|
415
|
-
}
|
|
416
|
-
return out;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/** The port of every live deck that registered one. Same directory and the same
|
|
420
|
-
* liveness check the election uses — a record whose process is gone is a
|
|
421
|
-
* leftover, not a deck whose tabs should be excused. */
|
|
422
|
-
export async function registeredDeckPorts(deps = {}) {
|
|
423
|
-
if (deps.registeredDeckPorts) return deps.registeredDeckPorts();
|
|
424
|
-
const dir = join(claudeConfigDir(), "agent-dag");
|
|
425
|
-
let files;
|
|
426
|
-
try { files = await readdir(dir); } catch { return []; }
|
|
427
|
-
const ports = [];
|
|
428
|
-
for (const f of files) {
|
|
429
|
-
if (!f.endsWith(".json")) continue;
|
|
430
|
-
try {
|
|
431
|
-
const d = JSON.parse(await readFile(join(dir, f), "utf8"));
|
|
432
|
-
if (typeof d?.pid !== "number" || typeof d?.port !== "number") continue;
|
|
433
|
-
if (!pidAlive(d.pid)) continue;
|
|
434
|
-
ports.push(d.port);
|
|
435
|
-
} catch { /* corrupt, or gone between listing and read */ }
|
|
436
|
-
}
|
|
437
|
-
return ports;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* What the watch has been doing, newest first.
|
|
443
|
-
*
|
|
444
|
-
* The shell tool this descends from printed a running commentary — armed,
|
|
445
|
-
* standing down, still watching, nothing found — and that commentary was most
|
|
446
|
-
* of what made it trustworthy: you could see it working rather than take its
|
|
447
|
-
* silence on faith. A panel that only ever shows a list has no way to say "I
|
|
448
|
-
* looked, and there was nothing", which reads identically to "I am not looking".
|
|
449
|
-
*
|
|
450
|
-
* In memory and bounded. It is a record of what this process did since it
|
|
451
|
-
* started, not an audit trail — the archive on disk is the thing that must
|
|
452
|
-
* survive, and it already does.
|
|
453
|
-
*/
|
|
454
|
-
const LOG_MAX = 200;
|
|
455
|
-
const logLines = [];
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* One line of what the watch did.
|
|
459
|
-
*
|
|
460
|
-
* FIVE LEVELS, AND THEY ARE NOT SEVERITIES. `find` is the only one that means
|
|
461
|
-
* something was found; `act` is the reader themselves, changing a setting or
|
|
462
|
-
* the switch — the only lines in the file a person put there, and the ones they
|
|
463
|
-
* scan for when asking "what did I change and when"; `ok` is the deck working,
|
|
464
|
-
* `info` is the deck deciding not to work, and `warn` is the deck unable to.
|
|
465
|
-
*
|
|
466
|
-
* A log where every line is the same weight is a log nobody scans — and the one
|
|
467
|
-
* line worth catching here is a program having driven the browser, which is not
|
|
468
|
-
* an error and must not be dressed as one.
|
|
469
|
-
*
|
|
470
|
-
* @param {"find"|"act"|"ok"|"info"|"warn"} level
|
|
471
|
-
*/
|
|
472
|
-
function note(level, text, atMs = Date.now(), parts = null) {
|
|
473
|
-
// `parts` is the same line said as columns, for the one shape that HAS
|
|
474
|
-
// columns: a profile read. The panel aligns those into a grid, where the
|
|
475
|
-
// count lands in the same place on every row instead of at the end of a
|
|
476
|
-
// sentence whose length depends on the browser's name. Composed here rather
|
|
477
|
-
// than parsed back out of `text` in the client — a program that has to
|
|
478
|
-
// reverse its own formatting has two spellings of one fact and will
|
|
479
|
-
// eventually disagree with itself.
|
|
480
|
-
//
|
|
481
|
-
// Null for every other line, and that is not a gap: "still watching 2
|
|
482
|
-
// profiles" and "closed the tab" are the deck talking, not events with a
|
|
483
|
-
// browser and a number, and the panel renders them as a different kind of
|
|
484
|
-
// row on purpose.
|
|
485
|
-
logLines.unshift(parts ? { atMs, level, text, parts } : { atMs, level, text });
|
|
486
|
-
if (logLines.length > LOG_MAX) logLines.length = LOG_MAX;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
function watchLog() {
|
|
490
|
-
return logLines.slice();
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
/** Called by the settings route, which is the one moment worth a line of its
|
|
494
|
-
* own: everything else here is the deck reading, and this is the user acting. */
|
|
495
|
-
export function noteWatchSetting(text) {
|
|
496
|
-
note("act", text);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/**
|
|
500
|
-
* Whether THIS deck is the one that reacts and writes.
|
|
501
|
-
*
|
|
502
|
-
* ONE MACHINE, ONE STORE, AND USUALLY MORE THAN ONE DECK. The archive and the
|
|
503
|
-
* log live at a single path per machine, but running two decks is ordinary here
|
|
504
|
-
* — the repo has electWriters and a discovery directory precisely because it is.
|
|
505
|
-
* Both would read the same Chrome history, find the same new episode, and each
|
|
506
|
-
* write a line and fire a notification: one event, told twice.
|
|
507
|
-
*
|
|
508
|
-
* Verified rather than assumed: at the moment this was written, two decks were
|
|
509
|
-
* live on this machine (ports 4317 and 4393), so the collision is the ordinary
|
|
510
|
-
* case and not a corner.
|
|
511
|
-
*
|
|
512
|
-
* The rule is log-writer.mjs's, reused rather than reinvented: among live decks,
|
|
513
|
-
* the LOWEST PORT wins, with the pid breaking a tie a stale discovery file could
|
|
514
|
-
* invent. Deterministic, needs no lock file, and cannot strand the feature — a
|
|
515
|
-
* deck that reads a directory it cannot open decides it is alone, which for the
|
|
516
|
-
* common case of one deck is the right answer anyway.
|
|
517
|
-
*
|
|
518
|
-
* Reading only, never writing: this is a question about who else is running, and
|
|
519
|
-
* a watcher that had to claim something to answer it could leave the claim
|
|
520
|
-
* behind. The shell tool this descends from lost its lock on SIGHUP and then
|
|
521
|
-
* refused to watch anything ever again.
|
|
522
|
-
*/
|
|
523
|
-
async function isReactingDeck(deps = {}) {
|
|
524
|
-
if (deps.isReactingDeck) return deps.isReactingDeck();
|
|
525
|
-
const dir = join(claudeConfigDir(), "agent-dag");
|
|
526
|
-
let files;
|
|
527
|
-
try { files = await readdir(dir); } catch { return true; } // cannot look — assume alone
|
|
528
|
-
|
|
529
|
-
let best = null;
|
|
530
|
-
for (const f of files) {
|
|
531
|
-
if (!f.endsWith(".json")) continue;
|
|
532
|
-
try {
|
|
533
|
-
const d = JSON.parse(await readFile(join(dir, f), "utf8"));
|
|
534
|
-
if (typeof d?.pid !== "number" || typeof d?.port !== "number") continue;
|
|
535
|
-
// ONLY DECKS THAT RUN THE WATCH GET A VOTE. This elected on port alone,
|
|
536
|
-
// so an older ccdeck that predates the feature won by holding the lower
|
|
537
|
-
// port and then wrote nothing — while the deck that has the watch stood
|
|
538
|
-
// down and also wrote nothing. Measured here: a v1.46 deck out of an npx
|
|
539
|
-
// cache held 4317, answered this route with the SPA's index.html, and the
|
|
540
|
-
// watch recorded nothing at all for as long as both were up. Findings on
|
|
541
|
-
// screen, an empty disk, and not one line anywhere saying why.
|
|
542
|
-
//
|
|
543
|
-
// An older deck has no such field, so it loses by construction rather
|
|
544
|
-
// than by a version comparison this would otherwise have to keep.
|
|
545
|
-
if (d.watch !== true) continue;
|
|
546
|
-
// A record whose process is gone is a leftover, not a rival.
|
|
547
|
-
if (!pidAlive(d.pid)) continue;
|
|
548
|
-
if (!best || d.port < best.port || (d.port === best.port && d.pid < best.pid)) best = d;
|
|
549
|
-
} catch { /* corrupt, or gone between listing and read */ }
|
|
550
|
-
}
|
|
551
|
-
return best === null || best.pid === process.pid;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
264
|
/**
|
|
555
265
|
* The browser survey, behind a short cache.
|
|
556
266
|
*
|
|
@@ -616,120 +326,6 @@ const _lastRead = new Map();
|
|
|
616
326
|
* read, so a row can report what was ADDED rather than the running total. */
|
|
617
327
|
const _lastCount = new Map();
|
|
618
328
|
|
|
619
|
-
/** What a profile has contributed before its first read. */
|
|
620
|
-
export function nothingSeen() {
|
|
621
|
-
return {
|
|
622
|
-
/** The oldest and newest visit times seen, and the newest a PERSON made. */
|
|
623
|
-
oldest: null,
|
|
624
|
-
newest: null,
|
|
625
|
-
human: null,
|
|
626
|
-
/** How many of those visits Chrome marked as coming from an API. */
|
|
627
|
-
byProgram: 0,
|
|
628
|
-
/**
|
|
629
|
-
* Findings whose verdict can no longer change, and those that still can.
|
|
630
|
-
*
|
|
631
|
-
* THE QUIET GATE IS WHY THIS IS TWO LISTS. `classify` reports a program
|
|
632
|
-
* navigation only when no HUMAN visit falls within `quietMs` of it, on
|
|
633
|
-
* either side, so a verdict depends on visits that may arrive in a later
|
|
634
|
-
* read. Classifying each read's rows on their own is a different
|
|
635
|
-
* computation, and wrong in the direction that matters: the person's visits
|
|
636
|
-
* from the read before would be missing from the gate, and a program page
|
|
637
|
-
* opened while they sat at the browser would be reported.
|
|
638
|
-
*
|
|
639
|
-
* What saves it is that the question SETTLES. Only a human visit less than
|
|
640
|
-
* `quietMs` from a candidate can silence it, and every later read returns
|
|
641
|
-
* visits newer than `newest`, so once `newest` is `quietMs` past a candidate
|
|
642
|
-
* nothing still to come can change its answer. Verdicts up to `settledTo`
|
|
643
|
-
* are final and kept; the ones after it are recomputed on every read from
|
|
644
|
-
* `window`, the same computation the whole-history classify was.
|
|
645
|
-
*/
|
|
646
|
-
settled: [],
|
|
647
|
-
open: [],
|
|
648
|
-
settledTo: -Infinity,
|
|
649
|
-
/**
|
|
650
|
-
* The only raw visits this module keeps between polls: every visit newer
|
|
651
|
-
* than `settledTo`, and the TIME of the newest visit a person made at or
|
|
652
|
-
* before it. Bounded by the clock, where the cache before #989 held every
|
|
653
|
-
* visit since the deck booted.
|
|
654
|
-
*
|
|
655
|
-
* NOT PRUNED BY THE GATE IN FORCE, SINCE #1131. It was everything newer
|
|
656
|
-
* than `settledTo - quietMs`, which is what that gate needs and less than a
|
|
657
|
-
* longer one does. With the gate lengthened from 15 minutes to 60, the
|
|
658
|
-
* person's visit forty minutes before a program page had already been
|
|
659
|
-
* dropped, and the next poll reported two pages that `classify` over the
|
|
660
|
-
* same rows under the 60-minute gate reports none of.
|
|
661
|
-
*
|
|
662
|
-
* Every verdict still open is newer than `settledTo`, so of the visits a
|
|
663
|
-
* person made at or before that line the newest is the nearest to every one
|
|
664
|
-
* of them, and the rest can never decide anything, under any gate: the hour
|
|
665
|
-
* the panel offers, or the day `normalise` accepts from a hand edit. Kept,
|
|
666
|
-
* that one visit makes the evidence complete whatever the gate is changed
|
|
667
|
-
* to. It is kept as a time and nothing else, because the gate asks when a
|
|
668
|
-
* person was at the browser and never where. What is held comes to one gate
|
|
669
|
-
* of browsing and one timestamp, where it was two gates of addresses.
|
|
670
|
-
*/
|
|
671
|
-
window: [],
|
|
672
|
-
/** The gate the open verdicts were last judged under, so a changed one is
|
|
673
|
-
* applied on the next poll whether or not that poll read anything. */
|
|
674
|
-
judgedUnder: null,
|
|
675
|
-
};
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/** Fold one real read's rows into what the profile has contributed, and judge
|
|
679
|
-
* again whatever is still open. Mutates `seen`, which is the object `_lastRead`
|
|
680
|
-
* holds. Exported for tests, with `nothingSeen`: what is held between polls
|
|
681
|
-
* is the question #989 was about, and a snapshot cannot see it. */
|
|
682
|
-
export function absorb(seen, rows, { quietMs, classifyOpts, browser }) {
|
|
683
|
-
// NOTHING NEW UNDER THE SAME GATE IS NOTHING TO DO. A gate that has changed
|
|
684
|
-
// since the open verdicts were judged is applied with no new rows at all
|
|
685
|
-
// (#1131): the settings route drops the cache, the read after it finds the
|
|
686
|
-
// file as it was and returns nothing, and returning here on that left a
|
|
687
|
-
// lengthened gate unapplied until the browser next wrote — the panel still
|
|
688
|
-
// listing a page the new gate hides.
|
|
689
|
-
if (rows.length === 0 && (seen.newest === null || seen.judgedUnder === quietMs)) return;
|
|
690
|
-
for (const row of rows) {
|
|
691
|
-
if (seen.oldest === null || row.timeMs < seen.oldest) seen.oldest = row.timeMs;
|
|
692
|
-
if (seen.newest === null || row.timeMs > seen.newest) seen.newest = row.timeMs;
|
|
693
|
-
// PAGES A PROGRAM OPENED, which is not the same as findings. A finding also
|
|
694
|
-
// has to clear the quiet gate; this is every navigation Chrome marked as
|
|
695
|
-
// coming from an API, whether or not anybody was at the keyboard. It is the
|
|
696
|
-
// figure the overview shows, because a panel about what programs did should
|
|
697
|
-
// count what programs did — the total row count it showed before was, on a
|
|
698
|
-
// measured profile, 78% the reader's own browsing.
|
|
699
|
-
if (isProgramNavigation(row.transition)) seen.byProgram += 1;
|
|
700
|
-
else if (seen.human === null || row.timeMs > seen.human) seen.human = row.timeMs;
|
|
701
|
-
}
|
|
702
|
-
// The visits still in play from earlier reads, then this read's.
|
|
703
|
-
const evidence = seen.window.concat(rows);
|
|
704
|
-
// MONOTONIC, so a quiet gate the reader has just lengthened cannot move the
|
|
705
|
-
// line back and re-open a verdict already kept, which would list that finding
|
|
706
|
-
// twice: once settled and once open.
|
|
707
|
-
const settleTo = Math.max(seen.settledTo, seen.newest - quietMs);
|
|
708
|
-
const verdicts = classify(evidence, classifyOpts)
|
|
709
|
-
// Tagged with the browser they came from, which is the one thing a reaction
|
|
710
|
-
// cannot work out for itself: closing a tab means telling ONE application
|
|
711
|
-
// to close it, and a finding that has forgotten which browser it was in can
|
|
712
|
-
// only be guessed at.
|
|
713
|
-
.map(f => ({ ...f, browser }));
|
|
714
|
-
for (const f of verdicts) {
|
|
715
|
-
if (f.timeMs > seen.settledTo && f.timeMs <= settleTo) seen.settled.push(f);
|
|
716
|
-
}
|
|
717
|
-
seen.open = verdicts.filter(f => f.timeMs > settleTo);
|
|
718
|
-
seen.settledTo = settleTo;
|
|
719
|
-
seen.judgedUnder = quietMs;
|
|
720
|
-
// Everything newer than the line, and of what a person did at or before it
|
|
721
|
-
// only the newest, as a time: `window` in `nothingSeen` says why that one
|
|
722
|
-
// visit is all any gate can ask for.
|
|
723
|
-
let person = null;
|
|
724
|
-
for (const r of evidence) {
|
|
725
|
-
if (r.timeMs > settleTo || isProgramNavigation(r.transition)) continue;
|
|
726
|
-
if (person === null || r.timeMs > person) person = r.timeMs;
|
|
727
|
-
}
|
|
728
|
-
seen.window = evidence.filter(r => r.timeMs > settleTo);
|
|
729
|
-
if (person !== null) seen.window.unshift({ url: "", timeMs: person, transition: 0 });
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
|
|
733
329
|
/** Whether the archive gained or altered anything worth a disk write. Compared
|
|
734
330
|
* on the shape a card is drawn from, so a re-read that found exactly the same
|
|
735
331
|
* episodes writes nothing — which is most polls, most of the time. */
|
|
@@ -742,6 +338,253 @@ function changedFrom(before, after) {
|
|
|
742
338
|
return false;
|
|
743
339
|
}
|
|
744
340
|
|
|
341
|
+
/**
|
|
342
|
+
* The feed's line for one read of one profile, and the running count its delta
|
|
343
|
+
* is taken against.
|
|
344
|
+
*
|
|
345
|
+
* One function because it is one rule: the delta is computed against the same
|
|
346
|
+
* map it then writes, so the deltas the feed shows add up to the total the
|
|
347
|
+
* overview shows (#989). A degraded read says why, a cached one — the file has
|
|
348
|
+
* not moved — says nothing at all, and a real read says what it added, if it
|
|
349
|
+
* added anything. `key` is the profile's browser/profile, the key `_lastCount`
|
|
350
|
+
* and `_lastRead` are kept under.
|
|
351
|
+
*/
|
|
352
|
+
function noteRead(profile, key, read, findings, now) {
|
|
353
|
+
const where = `${profile.name}/${profile.profile}`;
|
|
354
|
+
if (read.degraded) note("warn", `${where} — ${read.reason ?? "could not read"}`, now);
|
|
355
|
+
// A poll that found the file unchanged says nothing at all.
|
|
356
|
+
else if (read.cached) { /* silent */ }
|
|
357
|
+
else {
|
|
358
|
+
// `, 0 flagged` on every line is what made them all look alike: the
|
|
359
|
+
// count that matters is the one that is not zero, and printing the zero
|
|
360
|
+
// beside it buried the difference. Absence is the message.
|
|
361
|
+
// THE DELTA, NOT THE RUNNING TOTAL. `n` is every visit since this deck
|
|
362
|
+
// started, so re-reporting it made the feed a counter dressed as a log:
|
|
363
|
+
// "2 visits", "4 visits", "7 visits" are not three events of those
|
|
364
|
+
// sizes, they are one number growing. Each row is now a discrete fact —
|
|
365
|
+
// what this browser added since the last time the file moved — which is
|
|
366
|
+
// what a log line is supposed to be.
|
|
367
|
+
//
|
|
368
|
+
// And a read that added nothing says nothing. Chrome touches this file
|
|
369
|
+
// for reasons of its own, so an mtime that moved is not proof that
|
|
370
|
+
// anything happened; only a row count that grew is.
|
|
371
|
+
//
|
|
372
|
+
// THE COUNT, NOT THE ROWS, SINCE #989. The rows were the running total
|
|
373
|
+
// while every read began at the deck's start. Now they are only what is
|
|
374
|
+
// newer than the last read, and a cleared history returns none of them —
|
|
375
|
+
// the same answer a quiet minute gives — so the fall below could never
|
|
376
|
+
// be seen. `read.total` is the running total again, counted above the
|
|
377
|
+
// deck's start from the same copy. With no count to go on (a sqlite3 that
|
|
378
|
+
// printed it some other way) the rows are what is known to have been
|
|
379
|
+
// added, and that is never mistaken for a fall.
|
|
380
|
+
const n = read.total ?? (_lastCount.get(key) ?? 0) + read.rows.length;
|
|
381
|
+
const added = n - (_lastCount.get(key) ?? 0);
|
|
382
|
+
_lastCount.set(key, n);
|
|
383
|
+
if (added < 0) {
|
|
384
|
+
// THE COUNT WENT DOWN, which within one deck's run means one thing:
|
|
385
|
+
// the browsing history was truncated or cleared. Swallowing it broke
|
|
386
|
+
// the panel's own arithmetic — the deltas in the feed would no longer
|
|
387
|
+
// telescope to the total in the overview — and it hid the exact event
|
|
388
|
+
// this watch is built around. Whoever can drive this browser can clear
|
|
389
|
+
// its history with the same button the user has, and that is the one
|
|
390
|
+
// action that destroys the evidence.
|
|
391
|
+
note("warn",
|
|
392
|
+
`${where} — history shrank by ${(-added).toLocaleString("en-US")}; it was cleared or trimmed`, now,
|
|
393
|
+
{
|
|
394
|
+
browser: profile.name,
|
|
395
|
+
profile: profile.profile,
|
|
396
|
+
value: `${added.toLocaleString("en-US")} entries`,
|
|
397
|
+
flagged: 0,
|
|
398
|
+
});
|
|
399
|
+
} else if (added > 0 || findings.length > 0) {
|
|
400
|
+
const found = findings.length > 0 ? `, ${findings.length} flagged` : "";
|
|
401
|
+
note(findings.length > 0 ? "find" : "ok",
|
|
402
|
+
`${where} — ${added.toLocaleString("en-US")} new entr${added === 1 ? "y" : "ies"}${found}`, now,
|
|
403
|
+
{
|
|
404
|
+
browser: profile.name,
|
|
405
|
+
profile: profile.profile,
|
|
406
|
+
// `+` because the whole point of the change was that this is a
|
|
407
|
+
// DELTA and not a total, and a bare number in a column of
|
|
408
|
+
// numbers reads as a quantity of something rather than as
|
|
409
|
+
// growth. The noun matches the overview's caption above it.
|
|
410
|
+
value: `+${added.toLocaleString("en-US")} ${added === 1 ? "entry" : "entries"}`,
|
|
411
|
+
flagged: findings.length,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Write down what this poll found that the archive did not have, and react to
|
|
419
|
+
* it: the elected deck's half of a poll, taken only when the archive changed.
|
|
420
|
+
*
|
|
421
|
+
* Only the deck isReactingDeck elects gets here, so an episode is written and
|
|
422
|
+
* reacted to once however many decks are open — the others still show it,
|
|
423
|
+
* because reading the store is free. `live` is this poll's episodes and `kept`
|
|
424
|
+
* the archive with them merged in.
|
|
425
|
+
*/
|
|
426
|
+
async function recordAndReact(store, kept, live, { platform, now, deps }) {
|
|
427
|
+
// Only what is NEW gets a log line. mergeEpisodes replaces a run that has
|
|
428
|
+
// grown, so writing the whole set every time would repeat one episode once
|
|
429
|
+
// per page it gained. "The same episode" is the store's own key, the one
|
|
430
|
+
// the merge and the dismissals use, rather than a spelling of its own.
|
|
431
|
+
const known = new Set(store.episodes.map(e => episodeKey(e.host, e.startMs)));
|
|
432
|
+
// Already-dismissed episodes are not fresh news: the reader has seen them
|
|
433
|
+
// and said so, and notifying about one again is the panel arguing.
|
|
434
|
+
const fresh = undismissed(kept.filter(e => !known.has(episodeKey(e.host, e.startMs))), store.dismissed);
|
|
435
|
+
// Only when something actually arrived. "no new · 3 since this deck
|
|
436
|
+
// started" is the deck telling itself it wrote a file, which is not news.
|
|
437
|
+
if (fresh.length > 0) {
|
|
438
|
+
note("find", `${fresh.length} new episode${fresh.length === 1 ? "" : "s"} · ${kept.length} kept`, now);
|
|
439
|
+
}
|
|
440
|
+
// THE ARCHIVE IS OURS TO WRITE; THE OTHER TWO FIELDS ARE NOT. This poll
|
|
441
|
+
// takes about 400ms — a 21 MB History copy plus the sqlite read — and it
|
|
442
|
+
// used to write back the `dismissed` and `settings` it had read at the
|
|
443
|
+
// start, so a dismissal or a watch-off toggle made while it ran was
|
|
444
|
+
// reverted ten seconds later by the next poll. Re-merging inside the update
|
|
445
|
+
// keeps this poll's own answer and takes the other two from disk as
|
|
446
|
+
// they are at the moment of the write.
|
|
447
|
+
const merge = cur => ({
|
|
448
|
+
settings: cur.settings,
|
|
449
|
+
episodes: mergeEpisodes(cur.episodes, live, now),
|
|
450
|
+
dismissed: cur.dismissed,
|
|
451
|
+
});
|
|
452
|
+
if (deps.updateStore) await deps.updateStore(merge, undefined, deps);
|
|
453
|
+
else if (deps.writeStore) await deps.writeStore(merge(store), undefined, deps);
|
|
454
|
+
else await updateStore(merge, undefined, deps);
|
|
455
|
+
await (deps.appendLog ?? appendLog)(fresh, undefined, deps);
|
|
456
|
+
|
|
457
|
+
// REACT ONLY TO WHAT IS NEW, AND ONLY ONCE. `fresh` is the set that was not
|
|
458
|
+
// in the store a moment ago, so an episode still growing does not notify
|
|
459
|
+
// again on every page it gains — which is the difference between a watch
|
|
460
|
+
// and a nuisance.
|
|
461
|
+
//
|
|
462
|
+
// After the write, deliberately. A reaction that closed a tab and then lost
|
|
463
|
+
// the record of why would leave the user with a vanished page and nothing
|
|
464
|
+
// to read about it.
|
|
465
|
+
const reaction = store.settings.reaction;
|
|
466
|
+
if (fresh.length && performable(reaction, platform)) {
|
|
467
|
+
for (const episode of fresh) {
|
|
468
|
+
// A THROW IS NOT NOTHING. `catch(() => [])` turned a reaction that
|
|
469
|
+
// blew up into a reaction that had never been asked for, and the feed
|
|
470
|
+
// then said nothing at all about a finding the panel had promised to
|
|
471
|
+
// act on. The message goes in the line, because the one thing a reader
|
|
472
|
+
// needs when a reaction fails is which failure it was.
|
|
473
|
+
const acted = await (deps.react ?? react)(reaction, episode, { platform, deps })
|
|
474
|
+
.catch(err => [`reaction failed — ${err?.message ?? "unknown error"}`]);
|
|
475
|
+
// `could not` lines are the deck unable to do what it said it would,
|
|
476
|
+
// which is what `warn` is for; the rest is the reaction working.
|
|
477
|
+
for (const line of acted) {
|
|
478
|
+
note(/^(could not|reaction failed)/.test(line) ? "warn" : "find",
|
|
479
|
+
`${episode.host} — ${line}`, now);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* What the panel can honestly claim to know about, which is not the window it
|
|
487
|
+
* asked for: a profile whose history only goes back a week cannot answer for
|
|
488
|
+
* the month, and saying so is the difference between "nothing happened" and
|
|
489
|
+
* "nothing was recorded".
|
|
490
|
+
*
|
|
491
|
+
* Both of the snapshot's answers carry it — the one that read the browsers and
|
|
492
|
+
* the archive-only one — so it is built here once rather than spelled twice.
|
|
493
|
+
* `why` is the archive-only answer's, and absent from the other.
|
|
494
|
+
*/
|
|
495
|
+
async function coverageOf({ oldestVisitMs, lastHumanMs, quietMs, archived, now, why, deps }) {
|
|
496
|
+
return {
|
|
497
|
+
// When this deck started, which is the only moment the watch looks
|
|
498
|
+
// forward from — the panel says so rather than leaving a reader to guess
|
|
499
|
+
// how far back it went.
|
|
500
|
+
startedMs: STARTED_MS,
|
|
501
|
+
oldestVisitMs,
|
|
502
|
+
lastHumanMs,
|
|
503
|
+
quietMs: quietMs ?? 15 * 60_000,
|
|
504
|
+
logPath: logPath(),
|
|
505
|
+
// What that file holds on disk, both generations, beside its name
|
|
506
|
+
// (#989). It was the one store in this feature with no cap, and the panel
|
|
507
|
+
// named it without ever saying how large it had grown.
|
|
508
|
+
logBytes: await (deps.logSize ?? logSize)(),
|
|
509
|
+
// When the deck last FINISHED a poll, and how many it has done. The
|
|
510
|
+
// panel's liveness reads from these; the heartbeat row that used to
|
|
511
|
+
// carry it is gone. Not `lastWrittenMs` — that is the History file's
|
|
512
|
+
// mtime, a fact about the browser rather than about the watch, and on an
|
|
513
|
+
// idle machine it grows forever while the watch keeps looking.
|
|
514
|
+
checkedMs: _checkedMs,
|
|
515
|
+
checks: _checks,
|
|
516
|
+
// How many episodes this deck has seen since it started. Zero with the
|
|
517
|
+
// watch off is not a fault — it is the switch doing what it says — and the
|
|
518
|
+
// panel needs the number to be able to say which of the two it is.
|
|
519
|
+
archived,
|
|
520
|
+
now,
|
|
521
|
+
...(why === undefined ? {} : { why }),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Read one profile, and fold what the read found into what the profile has
|
|
527
|
+
* contributed since the deck started.
|
|
528
|
+
*
|
|
529
|
+
* This is the half of a poll #989 and #1131 were about — where a read starts,
|
|
530
|
+
* whether it counts, and where the next one starts — so it lives apart from
|
|
531
|
+
* what the snapshot does with the answer. Resolves to the read (real, cached,
|
|
532
|
+
* degraded, or overtaken and demoted to cached), the browser/profile key the
|
|
533
|
+
* accumulators are kept under, and the accumulator itself, already folded.
|
|
534
|
+
*/
|
|
535
|
+
async function readProfile(profile, { sinceChromeTime, copyDir, deps, quietMs, opts, exclude }) {
|
|
536
|
+
// From where this profile's last read finished, or from the deck's start
|
|
537
|
+
// on its first. The count is always taken above the deck's start, which
|
|
538
|
+
// is what makes it the running total noteRead's delta comes out of.
|
|
539
|
+
const floor = _floor.get(profile.historyPath) ?? sinceChromeTime;
|
|
540
|
+
let read = await visitsFor(profile, { sinceChromeTime: floor, countSince: sinceChromeTime, copyDir, deps });
|
|
541
|
+
// A READ THAT ANOTHER READ OVERTOOK IS NOT ABSORBED (#1131). One read in
|
|
542
|
+
// flight at a time is `fetchBrowserWatch`'s rule, and a forced read slipping
|
|
543
|
+
// past it is what counted five program pages as seven. This is the same
|
|
544
|
+
// rule where the floor is read and moved, so it holds for every caller: if
|
|
545
|
+
// the floor is not where it was when this read took it, another read of
|
|
546
|
+
// this profile finished meanwhile and absorbed the rows above it, which are
|
|
547
|
+
// the rows this one holds. Such a read is dropped whole and moves nothing.
|
|
548
|
+
// Its cache entry goes too, so a visit only it had seen is read on the next
|
|
549
|
+
// poll rather than whenever the browser next writes.
|
|
550
|
+
//
|
|
551
|
+
// ON THE FLOOR, NOT ON A VISIT ID. Rows carry no id, and giving them one
|
|
552
|
+
// means a second column in both backends' SELECT and a set of ids that
|
|
553
|
+
// either grows with every visit since boot — the retention #989 took out —
|
|
554
|
+
// or is pruned with the window and misses a copy older than it, which the
|
|
555
|
+
// first read after an afternoon with the panel shut returns. A row's time
|
|
556
|
+
// cannot stand in for an id either: `timeMs` is truncated to the
|
|
557
|
+
// millisecond while the floor is in microseconds, so two visits in one
|
|
558
|
+
// millisecond compare equal and the later is dropped as a copy of the
|
|
559
|
+
// first. The floor is exact, being the value every row of the read was
|
|
560
|
+
// selected against.
|
|
561
|
+
if (!read.cached && !read.degraded && (_floor.get(profile.historyPath) ?? sinceChromeTime) !== floor) {
|
|
562
|
+
cache.delete(profile.historyPath);
|
|
563
|
+
read = { ...read, rows: [], cached: true };
|
|
564
|
+
}
|
|
565
|
+
const key = `${profile.browser}/${profile.profile}`;
|
|
566
|
+
// Everything this profile has contributed since the deck started. A read
|
|
567
|
+
// that says "unchanged", or that could not be taken, means "as before", not
|
|
568
|
+
// "nothing": the list must not empty itself because one poll found the file
|
|
569
|
+
// untouched or the browser holding a lock.
|
|
570
|
+
const seen = _lastRead.get(key) ?? nothingSeen();
|
|
571
|
+
const judge = { quietMs, classifyOpts: { ...opts, exclude }, browser: profile.browser };
|
|
572
|
+
if (!read.cached && !read.degraded) {
|
|
573
|
+
// THE FLOOR MOVES HERE, and only on a read that succeeded. A read with
|
|
574
|
+
// nothing new hands its floor back as the watermark, so storing that
|
|
575
|
+
// changes nothing.
|
|
576
|
+
_floor.set(profile.historyPath, read.watermark);
|
|
577
|
+
absorb(seen, read.rows, judge);
|
|
578
|
+
_lastRead.set(key, seen);
|
|
579
|
+
} else {
|
|
580
|
+
// Nothing read, and the gate may still have changed since the open
|
|
581
|
+
// verdicts were judged: `?quiet=` overrides it per request and drops no
|
|
582
|
+
// cache. `absorb` does nothing unless it has.
|
|
583
|
+
absorb(seen, [], judge);
|
|
584
|
+
}
|
|
585
|
+
return { read, key, seen };
|
|
586
|
+
}
|
|
587
|
+
|
|
745
588
|
/**
|
|
746
589
|
* Everything the panel draws, in one object.
|
|
747
590
|
*
|
|
@@ -813,21 +656,17 @@ export async function browserWatchSnapshot({
|
|
|
813
656
|
// to say. The panel renders the difference.
|
|
814
657
|
relay: null,
|
|
815
658
|
episodes: archived,
|
|
816
|
-
coverage: {
|
|
817
|
-
startedMs: STARTED_MS,
|
|
659
|
+
coverage: await coverageOf({
|
|
818
660
|
oldestVisitMs: null,
|
|
819
661
|
lastHumanMs: null,
|
|
820
|
-
quietMs
|
|
821
|
-
logPath: logPath(),
|
|
822
|
-
logBytes: await (deps.logSize ?? logSize)(),
|
|
823
|
-
checkedMs: _checkedMs,
|
|
824
|
-
checks: _checks,
|
|
662
|
+
quietMs,
|
|
825
663
|
archived: archived.length,
|
|
826
664
|
now,
|
|
665
|
+
deps,
|
|
827
666
|
// Said rather than implied: a reader who wonders why the profile list
|
|
828
667
|
// is empty gets the reason, in the same word the switch uses.
|
|
829
668
|
why: "the watch is off, so no browser was read on this poll",
|
|
830
|
-
},
|
|
669
|
+
}),
|
|
831
670
|
degraded: false,
|
|
832
671
|
};
|
|
833
672
|
}
|
|
@@ -863,120 +702,11 @@ export async function browserWatchSnapshot({
|
|
|
863
702
|
let lastHuman = null;
|
|
864
703
|
|
|
865
704
|
for (const profile of profiles) {
|
|
866
|
-
|
|
867
|
-
// on its first. The count is always taken above the deck's start, which
|
|
868
|
-
// is what makes it the running total the delta below comes out of.
|
|
869
|
-
const floor = _floor.get(profile.historyPath) ?? sinceChromeTime;
|
|
870
|
-
let read = await visitsFor(profile, { sinceChromeTime: floor, countSince: sinceChromeTime, copyDir, deps });
|
|
871
|
-
// A READ THAT ANOTHER READ OVERTOOK IS NOT ABSORBED (#1131). One read in
|
|
872
|
-
// flight at a time is `fetchBrowserWatch`'s rule, and a forced read slipping
|
|
873
|
-
// past it is what counted five program pages as seven. This is the same
|
|
874
|
-
// rule where the floor is read and moved, so it holds for every caller: if
|
|
875
|
-
// the floor is not where it was when this read took it, another read of
|
|
876
|
-
// this profile finished meanwhile and absorbed the rows above it, which are
|
|
877
|
-
// the rows this one holds. Such a read is dropped whole and moves nothing.
|
|
878
|
-
// Its cache entry goes too, so a visit only it had seen is read on the next
|
|
879
|
-
// poll rather than whenever the browser next writes.
|
|
880
|
-
//
|
|
881
|
-
// ON THE FLOOR, NOT ON A VISIT ID. Rows carry no id, and giving them one
|
|
882
|
-
// means a second column in both backends' SELECT and a set of ids that
|
|
883
|
-
// either grows with every visit since boot — the retention #989 took out —
|
|
884
|
-
// or is pruned with the window and misses a copy older than it, which the
|
|
885
|
-
// first read after an afternoon with the panel shut returns. A row's time
|
|
886
|
-
// cannot stand in for an id either: `timeMs` is truncated to the
|
|
887
|
-
// millisecond while the floor is in microseconds, so two visits in one
|
|
888
|
-
// millisecond compare equal and the later is dropped as a copy of the
|
|
889
|
-
// first. The floor is exact, being the value every row of the read was
|
|
890
|
-
// selected against.
|
|
891
|
-
if (!read.cached && !read.degraded && (_floor.get(profile.historyPath) ?? sinceChromeTime) !== floor) {
|
|
892
|
-
cache.delete(profile.historyPath);
|
|
893
|
-
read = { ...read, rows: [], cached: true };
|
|
894
|
-
}
|
|
705
|
+
const { read, key, seen } = await readProfile(profile, { sinceChromeTime, copyDir, deps, quietMs, opts, exclude });
|
|
895
706
|
if (read.degraded) anyDegraded = true;
|
|
896
|
-
const key = `${profile.browser}/${profile.profile}`;
|
|
897
|
-
// Everything this profile has contributed since the deck started. A read
|
|
898
|
-
// that says "unchanged", or that could not be taken, means "as before", not
|
|
899
|
-
// "nothing": the list must not empty itself because one poll found the file
|
|
900
|
-
// untouched or the browser holding a lock.
|
|
901
|
-
const seen = _lastRead.get(key) ?? nothingSeen();
|
|
902
|
-
const judge = { quietMs, classifyOpts: { ...opts, exclude }, browser: profile.browser };
|
|
903
|
-
if (!read.cached && !read.degraded) {
|
|
904
|
-
// THE FLOOR MOVES HERE, and only on a read that succeeded. A read with
|
|
905
|
-
// nothing new hands its floor back as the watermark, so storing that
|
|
906
|
-
// changes nothing.
|
|
907
|
-
_floor.set(profile.historyPath, read.watermark);
|
|
908
|
-
absorb(seen, read.rows, judge);
|
|
909
|
-
_lastRead.set(key, seen);
|
|
910
|
-
} else {
|
|
911
|
-
// Nothing read, and the gate may still have changed since the open
|
|
912
|
-
// verdicts were judged: `?quiet=` overrides it per request and drops no
|
|
913
|
-
// cache. `absorb` does nothing unless it has.
|
|
914
|
-
absorb(seen, [], judge);
|
|
915
|
-
}
|
|
916
707
|
const findings = seen.settled.concat(seen.open);
|
|
917
708
|
const { oldest, human, byProgram } = seen;
|
|
918
|
-
|
|
919
|
-
if (read.degraded) note("warn", `${where} — ${read.reason ?? "could not read"}`, now);
|
|
920
|
-
// A poll that found the file unchanged says nothing at all.
|
|
921
|
-
else if (read.cached) { /* silent */ }
|
|
922
|
-
else {
|
|
923
|
-
// `, 0 flagged` on every line is what made them all look alike: the
|
|
924
|
-
// count that matters is the one that is not zero, and printing the zero
|
|
925
|
-
// beside it buried the difference. Absence is the message.
|
|
926
|
-
// THE DELTA, NOT THE RUNNING TOTAL. `n` is every visit since this deck
|
|
927
|
-
// started, so re-reporting it made the feed a counter dressed as a log:
|
|
928
|
-
// "2 visits", "4 visits", "7 visits" are not three events of those
|
|
929
|
-
// sizes, they are one number growing. Each row is now a discrete fact —
|
|
930
|
-
// what this browser added since the last time the file moved — which is
|
|
931
|
-
// what a log line is supposed to be.
|
|
932
|
-
//
|
|
933
|
-
// And a read that added nothing says nothing. Chrome touches this file
|
|
934
|
-
// for reasons of its own, so an mtime that moved is not proof that
|
|
935
|
-
// anything happened; only a row count that grew is.
|
|
936
|
-
//
|
|
937
|
-
// THE COUNT, NOT THE ROWS, SINCE #989. The rows were the running total
|
|
938
|
-
// while every read began at the deck's start. Now they are only what is
|
|
939
|
-
// newer than the last read, and a cleared history returns none of them —
|
|
940
|
-
// the same answer a quiet minute gives — so the fall below could never
|
|
941
|
-
// be seen. `read.total` is the running total again, counted above the
|
|
942
|
-
// deck's start from the same copy. With no count to go on (a sqlite3 that
|
|
943
|
-
// printed it some other way) the rows are what is known to have been
|
|
944
|
-
// added, and that is never mistaken for a fall.
|
|
945
|
-
const n = read.total ?? (_lastCount.get(key) ?? 0) + read.rows.length;
|
|
946
|
-
const added = n - (_lastCount.get(key) ?? 0);
|
|
947
|
-
_lastCount.set(key, n);
|
|
948
|
-
if (added < 0) {
|
|
949
|
-
// THE COUNT WENT DOWN, which within one deck's run means one thing:
|
|
950
|
-
// the browsing history was truncated or cleared. Swallowing it broke
|
|
951
|
-
// the panel's own arithmetic — the deltas in the feed would no longer
|
|
952
|
-
// telescope to the total in the overview — and it hid the exact event
|
|
953
|
-
// this watch is built around. Whoever can drive this browser can clear
|
|
954
|
-
// its history with the same button the user has, and that is the one
|
|
955
|
-
// action that destroys the evidence.
|
|
956
|
-
note("warn",
|
|
957
|
-
`${where} — history shrank by ${(-added).toLocaleString("en-US")}; it was cleared or trimmed`, now,
|
|
958
|
-
{
|
|
959
|
-
browser: profile.name,
|
|
960
|
-
profile: profile.profile,
|
|
961
|
-
value: `${added.toLocaleString("en-US")} entries`,
|
|
962
|
-
flagged: 0,
|
|
963
|
-
});
|
|
964
|
-
} else if (added > 0 || findings.length > 0) {
|
|
965
|
-
const found = findings.length > 0 ? `, ${findings.length} flagged` : "";
|
|
966
|
-
note(findings.length > 0 ? "find" : "ok",
|
|
967
|
-
`${where} — ${added.toLocaleString("en-US")} new entr${added === 1 ? "y" : "ies"}${found}`, now,
|
|
968
|
-
{
|
|
969
|
-
browser: profile.name,
|
|
970
|
-
profile: profile.profile,
|
|
971
|
-
// `+` because the whole point of the change was that this is a
|
|
972
|
-
// DELTA and not a total, and a bare number in a column of
|
|
973
|
-
// numbers reads as a quantity of something rather than as
|
|
974
|
-
// growth. The noun matches the overview's caption above it.
|
|
975
|
-
value: `+${added.toLocaleString("en-US")} ${added === 1 ? "entry" : "entries"}`,
|
|
976
|
-
flagged: findings.length,
|
|
977
|
-
});
|
|
978
|
-
}
|
|
979
|
-
}
|
|
709
|
+
noteRead(profile, key, read, findings, now);
|
|
980
710
|
allFindings = allFindings.concat(findings);
|
|
981
711
|
if (oldest !== null && (oldestSeen === null || oldest < oldestSeen)) oldestSeen = oldest;
|
|
982
712
|
if (human !== null && (lastHuman === null || human > lastHuman)) lastHuman = human;
|
|
@@ -1029,63 +759,7 @@ export async function browserWatchSnapshot({
|
|
|
1029
759
|
// reading the store is free and a second panel that went blank would be a
|
|
1030
760
|
// worse bug than the one this prevents.
|
|
1031
761
|
const acting = enabled ? await isReactingDeck(deps) : false;
|
|
1032
|
-
if (acting && changedFrom(store.episodes, kept)) {
|
|
1033
|
-
// Only what is NEW gets a log line. mergeEpisodes replaces a run that has
|
|
1034
|
-
// grown, so writing the whole set every time would repeat one episode once
|
|
1035
|
-
// per page it gained.
|
|
1036
|
-
const known = new Set(store.episodes.map(e => `${e.host} ${e.startMs}`));
|
|
1037
|
-
// Already-dismissed episodes are not fresh news: the reader has seen them
|
|
1038
|
-
// and said so, and notifying about one again is the panel arguing.
|
|
1039
|
-
const fresh = undismissed(kept.filter(e => !known.has(`${e.host} ${e.startMs}`)), store.dismissed);
|
|
1040
|
-
// Only when something actually arrived. "no new · 3 since this deck
|
|
1041
|
-
// started" is the deck telling itself it wrote a file, which is not news.
|
|
1042
|
-
if (fresh.length > 0) {
|
|
1043
|
-
note("find", `${fresh.length} new episode${fresh.length === 1 ? "" : "s"} · ${kept.length} kept`, now);
|
|
1044
|
-
}
|
|
1045
|
-
// THE ARCHIVE IS OURS TO WRITE; THE OTHER TWO FIELDS ARE NOT. This poll
|
|
1046
|
-
// takes about 400ms — a 21 MB History copy plus the sqlite read — and it
|
|
1047
|
-
// used to write back the `dismissed` and `settings` it had read at the
|
|
1048
|
-
// start, so a dismissal or a watch-off toggle made while it ran was
|
|
1049
|
-
// reverted ten seconds later by the next poll. Re-merging inside the update
|
|
1050
|
-
// keeps this function's own answer and takes the other two from disk as
|
|
1051
|
-
// they are at the moment of the write.
|
|
1052
|
-
const merge = cur => ({
|
|
1053
|
-
settings: cur.settings,
|
|
1054
|
-
episodes: mergeEpisodes(cur.episodes, live, now),
|
|
1055
|
-
dismissed: cur.dismissed,
|
|
1056
|
-
});
|
|
1057
|
-
if (deps.updateStore) await deps.updateStore(merge, undefined, deps);
|
|
1058
|
-
else if (deps.writeStore) await deps.writeStore(merge(store), undefined, deps);
|
|
1059
|
-
else await updateStore(merge, undefined, deps);
|
|
1060
|
-
await (deps.appendLog ?? appendLog)(fresh, undefined, deps);
|
|
1061
|
-
|
|
1062
|
-
// REACT ONLY TO WHAT IS NEW, AND ONLY ONCE. `fresh` is the set that was not
|
|
1063
|
-
// in the store a moment ago, so an episode still growing does not notify
|
|
1064
|
-
// again on every page it gains — which is the difference between a watch
|
|
1065
|
-
// and a nuisance.
|
|
1066
|
-
//
|
|
1067
|
-
// After the write, deliberately. A reaction that closed a tab and then lost
|
|
1068
|
-
// the record of why would leave the user with a vanished page and nothing
|
|
1069
|
-
// to read about it.
|
|
1070
|
-
const reaction = store.settings.reaction;
|
|
1071
|
-
if (fresh.length && performable(reaction, platform)) {
|
|
1072
|
-
for (const episode of fresh) {
|
|
1073
|
-
// A THROW IS NOT NOTHING. `catch(() => [])` turned a reaction that
|
|
1074
|
-
// blew up into a reaction that had never been asked for, and the feed
|
|
1075
|
-
// then said nothing at all about a finding the panel had promised to
|
|
1076
|
-
// act on. The message goes in the line, because the one thing a reader
|
|
1077
|
-
// needs when a reaction fails is which failure it was.
|
|
1078
|
-
const acted = await (deps.react ?? react)(reaction, episode, { platform, deps })
|
|
1079
|
-
.catch(err => [`reaction failed — ${err?.message ?? "unknown error"}`]);
|
|
1080
|
-
// `could not` lines are the deck unable to do what it said it would,
|
|
1081
|
-
// which is what `warn` is for; the rest is the reaction working.
|
|
1082
|
-
for (const line of acted) {
|
|
1083
|
-
note(/^(could not|reaction failed)/.test(line) ? "warn" : "find",
|
|
1084
|
-
`${episode.host} — ${line}`, now);
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
762
|
+
if (acting && changedFrom(store.episodes, kept)) await recordAndReact(store, kept, live, { platform, now, deps });
|
|
1089
763
|
// FILTERED ON BOTH PATHS, because the panel builds episodes from the
|
|
1090
764
|
// browser's own history on every poll: dropping only the archived copy would
|
|
1091
765
|
// be undone within ten seconds by the next read of the same visits.
|
|
@@ -1112,38 +786,16 @@ export async function browserWatchSnapshot({
|
|
|
1112
786
|
profiles: reports,
|
|
1113
787
|
browsers,
|
|
1114
788
|
episodes,
|
|
1115
|
-
coverage: {
|
|
1116
|
-
// What the panel can honestly claim to know about, which is not the
|
|
1117
|
-
// window it asked for: a profile whose history only goes back a week
|
|
1118
|
-
// cannot answer for the month, and saying so is the difference between
|
|
1119
|
-
// "nothing happened" and "nothing was recorded".
|
|
1120
|
-
// When this deck started, which is the only moment the watch looks
|
|
1121
|
-
// forward from — the panel says so rather than leaving a reader to guess
|
|
1122
|
-
// how far back it went.
|
|
1123
|
-
startedMs: sinceMs,
|
|
789
|
+
coverage: await coverageOf({
|
|
1124
790
|
oldestVisitMs: oldestSeen,
|
|
1125
791
|
// Null when nobody has browsed since the deck started, which is itself
|
|
1126
792
|
// the answer: the gate is already open.
|
|
1127
793
|
lastHumanMs: lastHuman,
|
|
1128
|
-
quietMs
|
|
1129
|
-
logPath: logPath(),
|
|
1130
|
-
// What that file holds on disk, both generations, beside its name
|
|
1131
|
-
// (#989). It was the one store in this feature with no cap, and the panel
|
|
1132
|
-
// named it without ever saying how large it had grown.
|
|
1133
|
-
logBytes: await (deps.logSize ?? logSize)(),
|
|
1134
|
-
// When the deck last FINISHED a poll, and how many it has done. The
|
|
1135
|
-
// panel's liveness reads from these; the heartbeat row that used to
|
|
1136
|
-
// carry it is gone. Not `lastWrittenMs` — that is the History file's
|
|
1137
|
-
// mtime, a fact about the browser rather than about the watch, and on an
|
|
1138
|
-
// idle machine it grows forever while the watch keeps looking.
|
|
1139
|
-
checkedMs: _checkedMs,
|
|
1140
|
-
checks: _checks,
|
|
1141
|
-
// How many episodes this deck has seen since it started. Zero with the
|
|
1142
|
-
// watch off is not a fault — it is the switch doing what it says — and the
|
|
1143
|
-
// panel needs the number to be able to say which of the two it is.
|
|
794
|
+
quietMs,
|
|
1144
795
|
archived: undismissed(kept, store.dismissed).length,
|
|
1145
796
|
now,
|
|
1146
|
-
|
|
797
|
+
deps,
|
|
798
|
+
}),
|
|
1147
799
|
degraded: anyDegraded,
|
|
1148
800
|
};
|
|
1149
801
|
}
|