@swimmingliu/autovpn 2.2.3 → 2.3.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/dist/cli/accounts-commands.js +55 -4
- package/dist/cli/output.js +1 -1
- package/dist/pipeline/credential-source.js +9 -9
- package/dist/pool/account-store.js +174 -3
- package/dist/server/runtime.js +208 -31
- package/dist/web/renderer/analytics.bundle.js +5 -5
- package/dist/web/renderer/i18n.js +1 -1
- package/dist/web/renderer/views.js +5 -1
- package/dist/web/renderer/workspace.css +59 -6
- package/package.json +1 -1
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,18 +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
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
for (const event of events) {
|
|
227
|
-
if (event.type === 'run_started' || event.type === 'resume_latest_state' || event.type === 'resume_pipeline_state') {
|
|
228
|
-
finished = false;
|
|
229
|
-
}
|
|
230
|
-
else if (event.type === 'summary' || event.type === 'run_failed' || event.type === 'finished') {
|
|
231
|
-
finished = true;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
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]);
|
|
234
326
|
let fresh = false;
|
|
235
327
|
try {
|
|
236
328
|
fresh = Date.now() - fs.statSync(filePath).mtimeMs < 2 * 60 * 1000;
|
|
@@ -467,6 +559,14 @@ export function createServerRuntime(options) {
|
|
|
467
559
|
let unsubscribeArtifactLogs;
|
|
468
560
|
let followedArtifactDir = '';
|
|
469
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 = '';
|
|
470
570
|
const pruneHistory = createHistoryPruner(options);
|
|
471
571
|
const subscribers = new Set();
|
|
472
572
|
const startDetachedRun = options.startDetachedRun ?? defaultStartDetachedRun;
|
|
@@ -478,6 +578,12 @@ export function createServerRuntime(options) {
|
|
|
478
578
|
subscriber(event);
|
|
479
579
|
}
|
|
480
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?.();
|
|
481
587
|
/**
|
|
482
588
|
* Follow a plain event log that no job directory owns.
|
|
483
589
|
*
|
|
@@ -488,6 +594,8 @@ export function createServerRuntime(options) {
|
|
|
488
594
|
* page needs sub-second freshness, not inotify precision.
|
|
489
595
|
*/
|
|
490
596
|
function followArtifactEvents(artifactDir) {
|
|
597
|
+
if (closed)
|
|
598
|
+
return;
|
|
491
599
|
if (followedArtifactDir === artifactDir && unsubscribeArtifactLogs) {
|
|
492
600
|
return;
|
|
493
601
|
}
|
|
@@ -495,7 +603,11 @@ export function createServerRuntime(options) {
|
|
|
495
603
|
followedArtifactDir = artifactDir;
|
|
496
604
|
const filePath = path.join(artifactDir, 'events.jsonl');
|
|
497
605
|
let cancelled = false;
|
|
498
|
-
|
|
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);
|
|
499
611
|
let pending = '';
|
|
500
612
|
const timer = setInterval(() => {
|
|
501
613
|
if (cancelled)
|
|
@@ -525,29 +637,40 @@ export function createServerRuntime(options) {
|
|
|
525
637
|
}
|
|
526
638
|
const lines = chunk.split(/\r?\n/);
|
|
527
639
|
pending = lines.pop() ?? '';
|
|
528
|
-
let
|
|
640
|
+
let finalState;
|
|
641
|
+
let lastEvent;
|
|
529
642
|
for (const line of lines) {
|
|
530
643
|
const event = parseJsonLine(line);
|
|
531
644
|
if (!event)
|
|
532
645
|
continue;
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
runState = nextRunState;
|
|
536
|
-
terminal = true;
|
|
537
|
-
}
|
|
646
|
+
const record = event;
|
|
647
|
+
lastEvent = record;
|
|
538
648
|
// Same contract as the job follower: a result line replayed after a
|
|
539
649
|
// page load must carry absolute database totals, or the renderer adds
|
|
540
650
|
// it on top of the snapshot it already hydrated and doubles the count.
|
|
541
|
-
const record = event;
|
|
542
651
|
const metricEvent = ['extract_iteration', 'extract_source_completed', 'speedtest_result', 'availability_link_result'].includes(String(record.type));
|
|
543
652
|
const progress = metricEvent ? readRunProgress(path.join(artifactDir, 'run.db')) : undefined;
|
|
544
653
|
publish(progress ? { ...record, progress_snapshot: { ...progress, artifact_dir: artifactDir } } : event);
|
|
545
|
-
|
|
546
|
-
|
|
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 });
|
|
547
669
|
}
|
|
548
|
-
// The run is over
|
|
549
|
-
//
|
|
550
|
-
|
|
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)) {
|
|
551
674
|
unsubscribeArtifactLogs?.();
|
|
552
675
|
unsubscribeArtifactLogs = undefined;
|
|
553
676
|
followedArtifactDir = '';
|
|
@@ -607,6 +730,52 @@ export function createServerRuntime(options) {
|
|
|
607
730
|
}
|
|
608
731
|
});
|
|
609
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
|
+
}
|
|
610
779
|
function restoreRunState(artifact) {
|
|
611
780
|
const env = options.env ?? process.env;
|
|
612
781
|
// A terminal state is a report about the *previous* run, not a latch. A new
|
|
@@ -621,8 +790,7 @@ export function createServerRuntime(options) {
|
|
|
621
790
|
if (liveArtifact && runState !== 'running' && runState !== 'stopping') {
|
|
622
791
|
unsubscribeLogs?.();
|
|
623
792
|
activeJobId = '';
|
|
624
|
-
|
|
625
|
-
followArtifactEvents(liveArtifact);
|
|
793
|
+
beginFollowingArtifact(liveArtifact);
|
|
626
794
|
return;
|
|
627
795
|
}
|
|
628
796
|
const job = latestJob(options.projectRoot, env);
|
|
@@ -646,8 +814,7 @@ export function createServerRuntime(options) {
|
|
|
646
814
|
// after the server restarts mid-run.
|
|
647
815
|
const artifactDir = String(artifact?.artifact_dir ?? '');
|
|
648
816
|
if (artifactDir && artifactEventLog(artifactDir).active) {
|
|
649
|
-
|
|
650
|
-
followArtifactEvents(artifactDir);
|
|
817
|
+
beginFollowingArtifact(artifactDir);
|
|
651
818
|
}
|
|
652
819
|
}
|
|
653
820
|
}
|
|
@@ -857,10 +1024,18 @@ export function createServerRuntime(options) {
|
|
|
857
1024
|
async stopRun() {
|
|
858
1025
|
if (runState !== 'running' || !activeJobId) {
|
|
859
1026
|
// A scheduled run has no managed job to signal; stop following its log
|
|
860
|
-
// 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;
|
|
861
1032
|
unsubscribeArtifactLogs?.();
|
|
862
1033
|
unsubscribeArtifactLogs = undefined;
|
|
863
1034
|
followedArtifactDir = '';
|
|
1035
|
+
if (runState === 'running') {
|
|
1036
|
+
runState = 'idle';
|
|
1037
|
+
publish({ type: 'server_state', run_state: runState });
|
|
1038
|
+
}
|
|
864
1039
|
return { ok: true, requested: false, run_state: runState };
|
|
865
1040
|
}
|
|
866
1041
|
runState = 'stopping';
|
|
@@ -886,6 +1061,8 @@ export function createServerRuntime(options) {
|
|
|
886
1061
|
return () => subscribers.delete(handler);
|
|
887
1062
|
},
|
|
888
1063
|
async close() {
|
|
1064
|
+
closed = true;
|
|
1065
|
+
clearInterval(watchTimer);
|
|
889
1066
|
unsubscribeLogs?.();
|
|
890
1067
|
unsubscribeArtifactLogs?.();
|
|
891
1068
|
if (!activeJobId) {
|