@swimmingliu/autovpn 2.2.2 → 2.2.4
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.
|
@@ -8,6 +8,7 @@ import { AccountStore, maskAccount } from '../pool/account-store.js';
|
|
|
8
8
|
import { BRANDS, brandFromSite, brandSite, normalizeBrand } from '../pool/brands.js';
|
|
9
9
|
import { FingerprintStore, resolveFingerprintCacheDir } from './brand-cache.js';
|
|
10
10
|
import { resolveUserRuntimeRoot } from '../runtime/paths.js';
|
|
11
|
+
import { profilePayload } from '../config/profile.js';
|
|
11
12
|
import { redactText } from '../runtime/redaction.js';
|
|
12
13
|
/**
|
|
13
14
|
* How many pooled accounts one source may try before it reports failure.
|
|
@@ -1986,12 +1987,17 @@ export function classifyMembershipRefresh(facts, today) {
|
|
|
1986
1987
|
if (expires && expires < today)
|
|
1987
1988
|
return { status: 'expired', reason: 'membership expired' };
|
|
1988
1989
|
const level = facts.membership_level;
|
|
1989
|
-
|
|
1990
|
+
// Links, not routes: the route table is filtered by flags, not by the
|
|
1991
|
+
// account's grade, so a free row can list paid-looking areas and still get
|
|
1992
|
+
// nothing but placeholders. The run path uses links for the same reason.
|
|
1993
|
+
const produced = Number(facts.produced_links ?? 0) > 0;
|
|
1990
1994
|
// A free-tier row (level 0/1) only ever yields placeholders. Retire it only
|
|
1991
1995
|
// when it produced nothing, exactly as the run path does, so a paid row the
|
|
1992
|
-
// vendor misreports as level 1 keeps its place.
|
|
1993
|
-
|
|
1996
|
+
// vendor misreports as level 1 keeps its place. A redirected row is exempt
|
|
1997
|
+
// for the run path's reason: the level is the storefront's grading.
|
|
1998
|
+
if (level !== undefined && level < 2 && !produced && !facts.redirected) {
|
|
1994
1999
|
return { status: 'retired', reason: 'no paid membership' };
|
|
2000
|
+
}
|
|
1995
2001
|
return { status: 'updated', reason: '' };
|
|
1996
2002
|
}
|
|
1997
2003
|
if (facts.membership_expired)
|
|
@@ -2001,6 +2007,38 @@ export function classifyMembershipRefresh(facts, today) {
|
|
|
2001
2007
|
return { status: 'retired', reason: 'login rejected' };
|
|
2002
2008
|
return { status: 'failed', reason: '' };
|
|
2003
2009
|
}
|
|
2010
|
+
/**
|
|
2011
|
+
* Where a membership sweep should log in for each brand.
|
|
2012
|
+
*
|
|
2013
|
+
* The sweep must reach the same host a run reaches, or its verdicts describe a
|
|
2014
|
+
* different account than the one the pool rotates: on a redirected row the run
|
|
2015
|
+
* rewrites `official_site` to the backend before any request, and the sweep
|
|
2016
|
+
* used to probe the storefront the run never touches. Read from the profile so
|
|
2017
|
+
* a redirected row is known here exactly as the run knows it.
|
|
2018
|
+
*/
|
|
2019
|
+
export function resolveExpiryProbeTargets(env, readSources) {
|
|
2020
|
+
const targets = new Map();
|
|
2021
|
+
let sources = {};
|
|
2022
|
+
try {
|
|
2023
|
+
// The profile path comes from the environment, so the project root argument
|
|
2024
|
+
// is not consulted; `profilePayload` reads the same file a run does.
|
|
2025
|
+
sources = readSources ? readSources() : (profilePayload(process.cwd(), env).sources ?? {});
|
|
2026
|
+
}
|
|
2027
|
+
catch {
|
|
2028
|
+
// No readable profile: every brand falls back to its own storefront.
|
|
2029
|
+
}
|
|
2030
|
+
for (const [name, config] of Object.entries(sources)) {
|
|
2031
|
+
const brand = normalizeBrand(name);
|
|
2032
|
+
if (!brand)
|
|
2033
|
+
continue;
|
|
2034
|
+
const backend = normalizeBrand(String(config?.backend_brand ?? '').trim());
|
|
2035
|
+
targets.set(brand, {
|
|
2036
|
+
officialSite: String(config?.official_site ?? '').trim() || brandSite(brand),
|
|
2037
|
+
...(backend && backend !== brand ? { backendBrand: backend } : {})
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
return targets;
|
|
2041
|
+
}
|
|
2004
2042
|
/**
|
|
2005
2043
|
* Learn every pooled account's real membership expiry by logging in.
|
|
2006
2044
|
*
|
|
@@ -2026,6 +2064,15 @@ export async function refreshAccountExpiry(input = {}) {
|
|
|
2026
2064
|
const requested = input.brand ? normalizeBrand(input.brand) : null;
|
|
2027
2065
|
// Every brand the pool knows, from the registry rather than a second list.
|
|
2028
2066
|
const brands = requested ? [requested] : BRANDS.map((brand) => brand.key);
|
|
2067
|
+
// How a run would reach each brand. A redirected row is graded by its own
|
|
2068
|
+
// storefront but logs in — and is graded again — against the backend it
|
|
2069
|
+
// reads, with the backend's APK. Probing the storefront instead put the sweep
|
|
2070
|
+
// on a host the run never touches (the deployment host cannot even resolve
|
|
2071
|
+
// 黑洞's storefront) and graded the account by a backend it never uses, so its
|
|
2072
|
+
// verdicts described a different account than the one the pool rotates.
|
|
2073
|
+
const probeFor = resolveExpiryProbeTargets(env);
|
|
2074
|
+
const probeSite = (brand) => probeFor.get(brand)?.officialSite ?? brandSite(brand);
|
|
2075
|
+
const probeBackend = (brand) => probeFor.get(brand)?.backendBrand;
|
|
2029
2076
|
try {
|
|
2030
2077
|
for (const brand of brands) {
|
|
2031
2078
|
let accounts = store.leaseAll(brand);
|
|
@@ -2034,10 +2081,13 @@ export async function refreshAccountExpiry(input = {}) {
|
|
|
2034
2081
|
if (accounts.length === 0)
|
|
2035
2082
|
continue;
|
|
2036
2083
|
// One memo per brand: the APK is inspected at most once for the whole
|
|
2037
|
-
// sweep, not once per account. The
|
|
2038
|
-
//
|
|
2039
|
-
//
|
|
2040
|
-
const brandKey =
|
|
2084
|
+
// sweep, not once per account. The key comes from the same function a run
|
|
2085
|
+
// uses, against the same probe source, so a redirected row is inspected
|
|
2086
|
+
// under the backend's prefix and shares the entry the run just cached.
|
|
2087
|
+
const brandKey = fingerprintKeyFor(brand, {
|
|
2088
|
+
official_site: probeSite(brand),
|
|
2089
|
+
...(probeBackend(brand) ? { brand_override: probeBackend(brand) } : {})
|
|
2090
|
+
});
|
|
2041
2091
|
const memo = createFingerprintMemo(fingerprintStore, brandKey);
|
|
2042
2092
|
await mapWithConcurrency(accounts, concurrency, async (leased) => {
|
|
2043
2093
|
const entry = {
|
|
@@ -2052,7 +2102,8 @@ export async function refreshAccountExpiry(input = {}) {
|
|
|
2052
2102
|
source_name: brand,
|
|
2053
2103
|
source: {
|
|
2054
2104
|
mode: 'credential',
|
|
2055
|
-
official_site:
|
|
2105
|
+
official_site: probeSite(brand),
|
|
2106
|
+
...(probeBackend(brand) ? { backend_brand: probeBackend(brand) } : {}),
|
|
2056
2107
|
account: leased.account,
|
|
2057
2108
|
password: leased.password,
|
|
2058
2109
|
platforms: ['android'],
|
|
@@ -2065,7 +2116,8 @@ export async function refreshAccountExpiry(input = {}) {
|
|
|
2065
2116
|
entry.expires_at = expires;
|
|
2066
2117
|
const verdict = classifyMembershipRefresh({
|
|
2067
2118
|
...result.diagnostics,
|
|
2068
|
-
produced_links: result.links.length
|
|
2119
|
+
produced_links: result.links.length,
|
|
2120
|
+
redirected: Boolean(probeBackend(brand))
|
|
2069
2121
|
}, todayText);
|
|
2070
2122
|
entry.status = verdict.status;
|
|
2071
2123
|
if (verdict.status === 'updated' || verdict.status === 'expired') {
|
package/dist/server/runtime.js
CHANGED
|
@@ -61,6 +61,22 @@ function preserveRedactedSecrets(incoming, current) {
|
|
|
61
61
|
}
|
|
62
62
|
return merged;
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Merge the live database totals over the report's per-source counts.
|
|
66
|
+
*
|
|
67
|
+
* The database is authoritative for link counts, but it knows nothing about
|
|
68
|
+
* what only the pipeline reports — `area_count`, the length of the frozen route
|
|
69
|
+
* list that the drawer's lap estimate reads. Spreading its map whole dropped
|
|
70
|
+
* those keys, so a finished run's estimate fell back to the reference area count
|
|
71
|
+
* (26 real areas shown as the 31-area default).
|
|
72
|
+
*/
|
|
73
|
+
function mergeSourceCounts(reported, progress) {
|
|
74
|
+
const merged = { ...(reported ?? {}) };
|
|
75
|
+
for (const [source, counts] of Object.entries(progress)) {
|
|
76
|
+
merged[source] = { ...(merged[source] ?? {}), ...counts };
|
|
77
|
+
}
|
|
78
|
+
return merged;
|
|
79
|
+
}
|
|
64
80
|
function normalizeLatestArtifact(projectRoot, env) {
|
|
65
81
|
const latest = artifactLatest(projectRoot, env);
|
|
66
82
|
if (!latest.ok || !latest.artifact_dir) {
|
|
@@ -68,12 +84,20 @@ function normalizeLatestArtifact(projectRoot, env) {
|
|
|
68
84
|
}
|
|
69
85
|
const preview = previewArtifact(String(latest.artifact_dir), { includeNodeRows: true });
|
|
70
86
|
const progress = readRunProgress(path.join(String(latest.artifact_dir), 'run.db'));
|
|
87
|
+
// A live run's own mirror is newer than the report it replaced at the last
|
|
88
|
+
// stage boundary, and it is what the page was just following. Replaying the
|
|
89
|
+
// report's `failed` summary over it is how a resumed run read as failed on
|
|
90
|
+
// every reload. History stays the report's when the log is finished.
|
|
91
|
+
const artifactLog = artifactEventLog(String(latest.artifact_dir));
|
|
92
|
+
const logEvents = artifactLog.events;
|
|
93
|
+
const liveLog = artifactLog.active && logEvents.length > 0;
|
|
71
94
|
return {
|
|
72
95
|
...latest,
|
|
73
96
|
...preview,
|
|
97
|
+
...(liveLog ? { run_status: 'running' } : {}),
|
|
74
98
|
// The report can still be empty mid-stage. Its display preview must not
|
|
75
99
|
// replace the authoritative totals that already exist in the run database.
|
|
76
|
-
...(progress ? { counts: { ...(preview.counts ?? {}), ...progress.counts }, source_counts: progress.source_counts, progress_snapshot: progress } : {}),
|
|
100
|
+
...(progress ? { counts: { ...(preview.counts ?? {}), ...progress.counts }, source_counts: mergeSourceCounts(preview.source_counts, progress.source_counts), progress_snapshot: progress } : {}),
|
|
77
101
|
outputFiles: (preview.files ?? []),
|
|
78
102
|
// The results page and the country donut read these rows. Answering with an
|
|
79
103
|
// empty array left a browser session at 0 nodes and an empty 国家分布 while
|
|
@@ -204,6 +228,82 @@ function latestJobEvents(job, maxEvents = 1000) {
|
|
|
204
228
|
return [];
|
|
205
229
|
}
|
|
206
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Events that announce a run has started.
|
|
233
|
+
*
|
|
234
|
+
* They carry no run status, so the follower reads them as `running`. A resume
|
|
235
|
+
* writes one of these (a pipeline resume writes it late, after re-running the
|
|
236
|
+
* extract it found incomplete), which is why the "is this log live" question
|
|
237
|
+
* cannot be answered from them alone — `artifactEventLog` asks it from the last
|
|
238
|
+
* line instead.
|
|
239
|
+
*/
|
|
240
|
+
const RUN_START_EVENTS = new Set([
|
|
241
|
+
'run_started',
|
|
242
|
+
'resume_latest_state',
|
|
243
|
+
'resume_pipeline_state',
|
|
244
|
+
'speedtest_resume_state'
|
|
245
|
+
]);
|
|
246
|
+
/** Same directory, even across a symlinked root (`/var` vs `/private/var`). */
|
|
247
|
+
function sameDir(left, right) {
|
|
248
|
+
if (!left || !right)
|
|
249
|
+
return false;
|
|
250
|
+
if (left === right)
|
|
251
|
+
return true;
|
|
252
|
+
try {
|
|
253
|
+
return fs.realpathSync(left) === fs.realpathSync(right);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Lines that end a run's event stream.
|
|
261
|
+
*
|
|
262
|
+
* A run is live while the last line in its log is something else. That rule
|
|
263
|
+
* lets one artifact log hold several runs: a resume appends to the log the
|
|
264
|
+
* interrupted run wrote, so the old `summary failed` sits in the middle of the
|
|
265
|
+
* file. Scanning for run boundaries instead was wrong in both directions — a
|
|
266
|
+
* pipeline resume re-runs extract before it writes its own boundary, so the
|
|
267
|
+
* file read as finished while it was working.
|
|
268
|
+
*/
|
|
269
|
+
function isTerminalEvent(event) {
|
|
270
|
+
return event.type === 'summary' || event.type === 'run_failed' || event.type === 'finished';
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Byte offset just past the last terminal line in an artifact event log.
|
|
274
|
+
*
|
|
275
|
+
* Everything before it belongs to a run that already ended and must not be
|
|
276
|
+
* replayed to the page. Zero when the file holds no terminal line at all.
|
|
277
|
+
*/
|
|
278
|
+
function artifactFollowOffset(filePath) {
|
|
279
|
+
let text = '';
|
|
280
|
+
try {
|
|
281
|
+
text = fs.readFileSync(filePath, 'utf8');
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
286
|
+
// Byte offsets, not character offsets: the log carries Chinese vendor
|
|
287
|
+
// messages, and seeking by character count would land mid-line.
|
|
288
|
+
let offset = 0;
|
|
289
|
+
let resumeAt = 0;
|
|
290
|
+
for (const rawLine of text.split('\n')) {
|
|
291
|
+
// Both writers terminate every line, so a line without its newline is the
|
|
292
|
+
// trailing fragment of a write that has not landed yet. Advancing past it
|
|
293
|
+
// would put the next read one byte into the following event.
|
|
294
|
+
const width = Buffer.byteLength(rawLine, 'utf8');
|
|
295
|
+
const terminated = offset + width < text.length;
|
|
296
|
+
const line = rawLine.trim();
|
|
297
|
+
if (line) {
|
|
298
|
+
const event = parseJsonLine(line);
|
|
299
|
+
if (event && typeof event === 'object' && isTerminalEvent(event)) {
|
|
300
|
+
resumeAt = offset + width + (terminated ? 1 : 0);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
offset += width + (terminated ? 1 : 0);
|
|
304
|
+
}
|
|
305
|
+
return resumeAt;
|
|
306
|
+
}
|
|
207
307
|
/**
|
|
208
308
|
* Stage/substep states and log lines for the latest artifact.
|
|
209
309
|
*
|
|
@@ -219,7 +319,10 @@ function artifactEventLog(artifactDir) {
|
|
|
219
319
|
try {
|
|
220
320
|
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/).filter(Boolean);
|
|
221
321
|
const events = lines.map((line) => parseJsonLine(line)).filter(Boolean);
|
|
222
|
-
|
|
322
|
+
// The last line decides: a run is live while it has not written a terminal
|
|
323
|
+
// event yet. Only the last one counts, or the interrupted run's summary
|
|
324
|
+
// would mark the resume that followed it finished.
|
|
325
|
+
const finished = events.length > 0 && isTerminalEvent(events[events.length - 1]);
|
|
223
326
|
let fresh = false;
|
|
224
327
|
try {
|
|
225
328
|
fresh = Date.now() - fs.statSync(filePath).mtimeMs < 2 * 60 * 1000;
|
|
@@ -456,6 +559,14 @@ export function createServerRuntime(options) {
|
|
|
456
559
|
let unsubscribeArtifactLogs;
|
|
457
560
|
let followedArtifactDir = '';
|
|
458
561
|
let expiryRefreshInFlight = false;
|
|
562
|
+
// Set by close(): the timer and the followers are torn down, and nothing may
|
|
563
|
+
// re-arm one afterwards. Without this a shutdown that follows a cron run can
|
|
564
|
+
// attach a fresh 15s poller and keep the process alive on its own work.
|
|
565
|
+
let closed = false;
|
|
566
|
+
// The artifact whose run the operator ended. Its log is still being written
|
|
567
|
+
// by a process the server does not own, so it stays "active" — the watcher
|
|
568
|
+
// must not read that as a run to pick back up.
|
|
569
|
+
let stoppedArtifactDir = '';
|
|
459
570
|
const pruneHistory = createHistoryPruner(options);
|
|
460
571
|
const subscribers = new Set();
|
|
461
572
|
const startDetachedRun = options.startDetachedRun ?? defaultStartDetachedRun;
|
|
@@ -467,6 +578,12 @@ export function createServerRuntime(options) {
|
|
|
467
578
|
subscriber(event);
|
|
468
579
|
}
|
|
469
580
|
};
|
|
581
|
+
// The watcher only reads two files, so a cadence well under the follower's
|
|
582
|
+
// is cheap; it is the difference between a page that waits for a reload and
|
|
583
|
+
// one that shows tonight's run.
|
|
584
|
+
const runWatchMs = Math.max(1000, Math.trunc(Number(options.runWatchIntervalMs ?? 15_000)) || 15_000);
|
|
585
|
+
const watchTimer = setInterval(watchForNewRun, runWatchMs);
|
|
586
|
+
watchTimer.unref?.();
|
|
470
587
|
/**
|
|
471
588
|
* Follow a plain event log that no job directory owns.
|
|
472
589
|
*
|
|
@@ -477,6 +594,8 @@ export function createServerRuntime(options) {
|
|
|
477
594
|
* page needs sub-second freshness, not inotify precision.
|
|
478
595
|
*/
|
|
479
596
|
function followArtifactEvents(artifactDir) {
|
|
597
|
+
if (closed)
|
|
598
|
+
return;
|
|
480
599
|
if (followedArtifactDir === artifactDir && unsubscribeArtifactLogs) {
|
|
481
600
|
return;
|
|
482
601
|
}
|
|
@@ -484,7 +603,11 @@ export function createServerRuntime(options) {
|
|
|
484
603
|
followedArtifactDir = artifactDir;
|
|
485
604
|
const filePath = path.join(artifactDir, 'events.jsonl');
|
|
486
605
|
let cancelled = false;
|
|
487
|
-
|
|
606
|
+
// Start at the current run, not at the top of the file: a resumed run
|
|
607
|
+
// shares the artifact with the run it resumes, and replaying the earlier
|
|
608
|
+
// run's `summary failed` republished a stale terminal state and then stopped
|
|
609
|
+
// this follower — the live run's later events never reached the page.
|
|
610
|
+
let offset = artifactFollowOffset(filePath);
|
|
488
611
|
let pending = '';
|
|
489
612
|
const timer = setInterval(() => {
|
|
490
613
|
if (cancelled)
|
|
@@ -514,29 +637,40 @@ export function createServerRuntime(options) {
|
|
|
514
637
|
}
|
|
515
638
|
const lines = chunk.split(/\r?\n/);
|
|
516
639
|
pending = lines.pop() ?? '';
|
|
517
|
-
let
|
|
640
|
+
let finalState;
|
|
641
|
+
let lastEvent;
|
|
518
642
|
for (const line of lines) {
|
|
519
643
|
const event = parseJsonLine(line);
|
|
520
644
|
if (!event)
|
|
521
645
|
continue;
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
runState = nextRunState;
|
|
525
|
-
terminal = true;
|
|
526
|
-
}
|
|
646
|
+
const record = event;
|
|
647
|
+
lastEvent = record;
|
|
527
648
|
// Same contract as the job follower: a result line replayed after a
|
|
528
649
|
// page load must carry absolute database totals, or the renderer adds
|
|
529
650
|
// it on top of the snapshot it already hydrated and doubles the count.
|
|
530
|
-
const record = event;
|
|
531
651
|
const metricEvent = ['extract_iteration', 'extract_source_completed', 'speedtest_result', 'availability_link_result'].includes(String(record.type));
|
|
532
652
|
const progress = metricEvent ? readRunProgress(path.join(artifactDir, 'run.db')) : undefined;
|
|
533
653
|
publish(progress ? { ...record, progress_snapshot: { ...progress, artifact_dir: artifactDir } } : event);
|
|
534
|
-
|
|
535
|
-
|
|
654
|
+
// The state is resolved over the whole chunk and published once, at the
|
|
655
|
+
// end: a chunk can hold a summary and the events of the resume that
|
|
656
|
+
// followed it, and publishing the summary's `failed` on the way past
|
|
657
|
+
// flashed it on the page before the resume took it back.
|
|
658
|
+
const nextRunState = eventRunState(record);
|
|
659
|
+
if (nextRunState) {
|
|
660
|
+
finalState = nextRunState;
|
|
661
|
+
}
|
|
662
|
+
else if (RUN_START_EVENTS.has(String(record.type))) {
|
|
663
|
+
finalState = 'running';
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (finalState) {
|
|
667
|
+
runState = finalState;
|
|
668
|
+
publish({ type: 'server_state', run_state: runState });
|
|
536
669
|
}
|
|
537
|
-
// The run is over
|
|
538
|
-
//
|
|
539
|
-
|
|
670
|
+
// The run is over only when its LAST line says so. A summary followed by
|
|
671
|
+
// a resume in the same chunk is a live run, and stopping here is what
|
|
672
|
+
// left the resumed run unfollowed.
|
|
673
|
+
if (lastEvent && isTerminalEvent(lastEvent)) {
|
|
540
674
|
unsubscribeArtifactLogs?.();
|
|
541
675
|
unsubscribeArtifactLogs = undefined;
|
|
542
676
|
followedArtifactDir = '';
|
|
@@ -596,6 +730,52 @@ export function createServerRuntime(options) {
|
|
|
596
730
|
}
|
|
597
731
|
});
|
|
598
732
|
}
|
|
733
|
+
/**
|
|
734
|
+
* Notice a run this server is not following yet.
|
|
735
|
+
*
|
|
736
|
+
* Consecutive nightly runs are the case: the follower clears itself at one
|
|
737
|
+
* run's summary, and the next night's artifact appears with no state load to
|
|
738
|
+
* trigger `restoreRunState`. Without this heartbeat an open page keeps last
|
|
739
|
+
* night's finished run until someone reloads — the exact "logs stopped
|
|
740
|
+
* refreshing" complaint, one run later.
|
|
741
|
+
*/
|
|
742
|
+
function watchForNewRun() {
|
|
743
|
+
if (closed)
|
|
744
|
+
return;
|
|
745
|
+
if (runState === 'running' || runState === 'stopping')
|
|
746
|
+
return;
|
|
747
|
+
if (unsubscribeLogs || activeJobId) {
|
|
748
|
+
// A managed job's follower leaves both guards set when its stream ends,
|
|
749
|
+
// and it can never see a later scheduled run. Release them once the job
|
|
750
|
+
// itself is no longer live, or the watcher stays dead after one click of
|
|
751
|
+
// 开始运行.
|
|
752
|
+
const job = latestJob(options.projectRoot, options.env ?? process.env);
|
|
753
|
+
if (job && ['running', 'stopping'].includes(String(job.status ?? '')))
|
|
754
|
+
return;
|
|
755
|
+
unsubscribeLogs?.();
|
|
756
|
+
unsubscribeLogs = undefined;
|
|
757
|
+
activeJobId = '';
|
|
758
|
+
}
|
|
759
|
+
if (unsubscribeArtifactLogs && followedArtifactDir)
|
|
760
|
+
return;
|
|
761
|
+
// This reads and folds the whole artifact log, which is the price of
|
|
762
|
+
// knowing whether the run is still going. It deliberately stops short of a
|
|
763
|
+
// state load, which would also open `run.db` (creating WAL sidecars), parse
|
|
764
|
+
// the final node list and run the retention sweep — every 15 seconds.
|
|
765
|
+
const latest = artifactLatest(options.projectRoot, options.env ?? process.env);
|
|
766
|
+
const artifactDir = String(latest?.artifact_dir ?? '');
|
|
767
|
+
if (!artifactDir || sameDir(artifactDir, stoppedArtifactDir))
|
|
768
|
+
return;
|
|
769
|
+
if (!artifactEventLog(artifactDir).active)
|
|
770
|
+
return;
|
|
771
|
+
beginFollowingArtifact(artifactDir);
|
|
772
|
+
}
|
|
773
|
+
/** Adopt an artifact log that is still being written as the live run. */
|
|
774
|
+
function beginFollowingArtifact(artifactDir) {
|
|
775
|
+
runState = 'running';
|
|
776
|
+
publish({ type: 'server_state', run_state: runState });
|
|
777
|
+
followArtifactEvents(artifactDir);
|
|
778
|
+
}
|
|
599
779
|
function restoreRunState(artifact) {
|
|
600
780
|
const env = options.env ?? process.env;
|
|
601
781
|
// A terminal state is a report about the *previous* run, not a latch. A new
|
|
@@ -610,8 +790,7 @@ export function createServerRuntime(options) {
|
|
|
610
790
|
if (liveArtifact && runState !== 'running' && runState !== 'stopping') {
|
|
611
791
|
unsubscribeLogs?.();
|
|
612
792
|
activeJobId = '';
|
|
613
|
-
|
|
614
|
-
followArtifactEvents(liveArtifact);
|
|
793
|
+
beginFollowingArtifact(liveArtifact);
|
|
615
794
|
return;
|
|
616
795
|
}
|
|
617
796
|
const job = latestJob(options.projectRoot, env);
|
|
@@ -635,8 +814,7 @@ export function createServerRuntime(options) {
|
|
|
635
814
|
// after the server restarts mid-run.
|
|
636
815
|
const artifactDir = String(artifact?.artifact_dir ?? '');
|
|
637
816
|
if (artifactDir && artifactEventLog(artifactDir).active) {
|
|
638
|
-
|
|
639
|
-
followArtifactEvents(artifactDir);
|
|
817
|
+
beginFollowingArtifact(artifactDir);
|
|
640
818
|
}
|
|
641
819
|
}
|
|
642
820
|
}
|
|
@@ -846,10 +1024,18 @@ export function createServerRuntime(options) {
|
|
|
846
1024
|
async stopRun() {
|
|
847
1025
|
if (runState !== 'running' || !activeJobId) {
|
|
848
1026
|
// A scheduled run has no managed job to signal; stop following its log
|
|
849
|
-
// so the page does not keep reporting a run the operator ended.
|
|
1027
|
+
// so the page does not keep reporting a run the operator ended. The
|
|
1028
|
+
// process itself keeps writing, so the log still looks live — remember
|
|
1029
|
+
// which artifact was ended, or the watcher picks it straight back up.
|
|
1030
|
+
if (followedArtifactDir)
|
|
1031
|
+
stoppedArtifactDir = followedArtifactDir;
|
|
850
1032
|
unsubscribeArtifactLogs?.();
|
|
851
1033
|
unsubscribeArtifactLogs = undefined;
|
|
852
1034
|
followedArtifactDir = '';
|
|
1035
|
+
if (runState === 'running') {
|
|
1036
|
+
runState = 'idle';
|
|
1037
|
+
publish({ type: 'server_state', run_state: runState });
|
|
1038
|
+
}
|
|
853
1039
|
return { ok: true, requested: false, run_state: runState };
|
|
854
1040
|
}
|
|
855
1041
|
runState = 'stopping';
|
|
@@ -875,6 +1061,8 @@ export function createServerRuntime(options) {
|
|
|
875
1061
|
return () => subscribers.delete(handler);
|
|
876
1062
|
},
|
|
877
1063
|
async close() {
|
|
1064
|
+
closed = true;
|
|
1065
|
+
clearInterval(watchTimer);
|
|
878
1066
|
unsubscribeLogs?.();
|
|
879
1067
|
unsubscribeArtifactLogs?.();
|
|
880
1068
|
if (!activeJobId) {
|
|
@@ -1252,7 +1252,10 @@ export function buildSourceIterationDraft(sources = {}, sourceCounts = {}) {
|
|
|
1252
1252
|
const storedDry = hasStoredLapValue(source.dry_laps) ? coercePositiveInteger(source.dry_laps, SOURCE_LAP_DEFAULTS.dry_laps) : null;
|
|
1253
1253
|
if (storedLaps !== null || storedDry !== null) {
|
|
1254
1254
|
source.max_laps = storedLaps ?? SOURCE_LAP_DEFAULTS.max_laps;
|
|
1255
|
-
|
|
1255
|
+
// Same clamp as the conversion below: a profile written by an older
|
|
1256
|
+
// build (or hand-edited) can already hold dry > max, and the drawer's
|
|
1257
|
+
// validator refuses that pair — an untouched row must stay savable.
|
|
1258
|
+
source.dry_laps = Math.min(source.max_laps, storedDry ?? SOURCE_LAP_DEFAULTS.dry_laps);
|
|
1256
1259
|
continue;
|
|
1257
1260
|
}
|
|
1258
1261
|
// Only a row that actually stored a legacy budget is converted; a fresh
|
|
@@ -1599,7 +1602,11 @@ function inferLogStage(line) {
|
|
|
1599
1602
|
// debug profile that sets them must survive a save from the drawer.
|
|
1600
1603
|
const OBSOLETE_SOURCE_FIELDS = [
|
|
1601
1604
|
'url', 'key', 'account', 'password', 'platform',
|
|
1602
|
-
'use_random_area', 'area_min', 'area_max',
|
|
1605
|
+
'use_random_area', 'area_min', 'area_max',
|
|
1606
|
+
// `max_areas` is deliberately absent: the credential flow still reads it as
|
|
1607
|
+
// an operator's cap on the frozen route list, and the drawer never rendered
|
|
1608
|
+
// it. Deleting it here made one untouched save drop a cap the run honours,
|
|
1609
|
+
// silently widening the crawl.
|
|
1603
1610
|
'resume_from_iteration', 'failure_limit'
|
|
1604
1611
|
];
|
|
1605
1612
|
|