@swimmingliu/autovpn 1.6.8 → 1.7.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 +5 -0
- package/dist/backend/node-backend.js +7 -22
- package/dist/jobs/commands.js +38 -7
- package/dist/jobs/read.js +9 -22
- package/dist/pipeline/availability.js +38 -36
- package/dist/pipeline/network-retry.js +54 -0
- package/dist/pipeline/orchestrator.js +989 -730
- package/dist/pipeline/proxy-runtime.js +130 -28
- package/dist/pipeline/run-store.js +602 -0
- package/dist/pipeline/speedtest.js +72 -75
- package/dist/pipeline/streaming-coordinator.js +154 -0
- package/dist/web/renderer/app.js +45 -6
- package/dist/web/renderer/i18n.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,6 +65,11 @@ autovpn run --project-root . --output jsonl
|
|
|
65
65
|
|
|
66
66
|
Runtime notes:
|
|
67
67
|
|
|
68
|
+
- Each run writes `artifact_dir/run.db`. This SQLite database is the authoritative local record for run state, node identity, stage results, and resume checkpoints. The `.txt` and `.json` files in the artifact directory are compatibility exports; do not use them as the source of truth for an in-progress run.
|
|
69
|
+
- Pipeline mode sends each newly discovered unique node from the extract callback to a reachability probe. Only reachable nodes receive the full speed measurement, and only nodes that pass the configured speed threshold proceed to availability checks. Dedupe, probe/speedtest, and availability can be active while extraction is still running, so their displayed totals can increase during the run.
|
|
70
|
+
- The streaming queues use bounded concurrency and backpressure. A slow speed or availability worker therefore limits queued work instead of allowing unbounded memory growth.
|
|
71
|
+
- `resume pipeline`, `resume speedtest`, `run --resume-latest`, and `retry-stage` continue from the SQLite checkpoints. Existing legacy artifact directories can be imported on resume; subsequent state is recorded in `run.db` while compatibility exports remain available.
|
|
72
|
+
- Pipeline mode does not apply the legacy global `max_download_candidates` ranking gate before availability. If that field remains in a profile, it only applies when the speed module is run independently in its legacy ranked-candidate mode.
|
|
68
73
|
- Detached job management runs in Node for `run --detach`, `jobs resume --detach`, and `jobs retry --detach`; detached run/resume/retry workers also use the Node CLI worker.
|
|
69
74
|
- Non-detached `retry-stage` runs through the Node backend for retryable artifact stages from `speedtest` through `verify`; non-detached `resume pipeline`, `resume speedtest`, and `run --resume-latest` continue existing sessions through the Node backend.
|
|
70
75
|
- Add `--skip-deploy --skip-verify` when you want an offline Node pipeline check.
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { createRequire } from 'node:module';
|
|
4
3
|
import { resumeNodePipeline, retryNodePipelineStage, runNodePipeline } from '../pipeline/orchestrator.js';
|
|
5
4
|
import { resolveArtifactsRoot } from '../runtime/paths.js';
|
|
6
|
-
|
|
5
|
+
import { readLatestStageStatuses, readRunStatus } from '../pipeline/run-store.js';
|
|
7
6
|
function unsupported(method) {
|
|
8
7
|
return new Error(`Node backend ${method} is not available in this execution path`);
|
|
9
8
|
}
|
|
@@ -12,7 +11,6 @@ function latestIncompleteRunArtifact(projectRoot, env) {
|
|
|
12
11
|
if (!fs.existsSync(artifactsRoot)) {
|
|
13
12
|
return undefined;
|
|
14
13
|
}
|
|
15
|
-
const { DatabaseSync } = require('node:sqlite');
|
|
16
14
|
const candidates = fs.readdirSync(artifactsRoot, { withFileTypes: true })
|
|
17
15
|
.filter((entry) => entry.isDirectory())
|
|
18
16
|
.map((entry) => {
|
|
@@ -23,27 +21,14 @@ function latestIncompleteRunArtifact(projectRoot, env) {
|
|
|
23
21
|
.filter((candidate) => fs.existsSync(candidate.dbPath))
|
|
24
22
|
.sort((left, right) => fs.statSync(right.artifactDir).mtimeMs - fs.statSync(left.artifactDir).mtimeMs);
|
|
25
23
|
for (const candidate of candidates) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
db = new DatabaseSync(candidate.dbPath);
|
|
29
|
-
const runRow = db.prepare('SELECT status FROM runs ORDER BY run_id DESC LIMIT 1').get();
|
|
30
|
-
const runStatus = String(runRow?.status ?? '').trim();
|
|
31
|
-
if (['success', 'failed', 'stopped'].includes(runStatus)) {
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
const stageRows = db.prepare('SELECT stage_name, status FROM stage_events ORDER BY rowid ASC').all();
|
|
35
|
-
const stageStatus = new Map(stageRows.map((row) => [String(row.stage_name ?? ''), String(row.status ?? '')]));
|
|
36
|
-
if (stageStatus.get('verify') === 'success') {
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
return candidate.artifactDir;
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
24
|
+
const runStatus = readRunStatus(candidate.dbPath);
|
|
25
|
+
if (!runStatus || ['success', 'failed', 'stopped', 'cancelled'].includes(runStatus)) {
|
|
42
26
|
continue;
|
|
43
27
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
28
|
+
const stageStatus = readLatestStageStatuses(candidate.dbPath);
|
|
29
|
+
if (stageStatus.verify === 'success')
|
|
30
|
+
continue;
|
|
31
|
+
return candidate.artifactDir;
|
|
47
32
|
}
|
|
48
33
|
return undefined;
|
|
49
34
|
}
|
package/dist/jobs/commands.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { RunStore, readRunStatus } from '../pipeline/run-store.js';
|
|
3
4
|
import { randomBytes } from 'node:crypto';
|
|
4
5
|
import { spawn as defaultSpawn } from 'node:child_process';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
@@ -45,18 +46,47 @@ function spawnDetached(command, args, job, options) {
|
|
|
45
46
|
function markArtifactStopped(job) {
|
|
46
47
|
const artifactDir = String(job.artifact_dir ?? '');
|
|
47
48
|
if (!artifactDir) {
|
|
48
|
-
return;
|
|
49
|
+
return undefined;
|
|
49
50
|
}
|
|
50
51
|
const reportPath = path.join(artifactDir, 'pipeline_report.json');
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
const dbPath = path.join(artifactDir, 'run.db');
|
|
53
|
+
let authoritativeStatus;
|
|
54
|
+
if (fs.existsSync(dbPath)) {
|
|
55
|
+
let runStore;
|
|
56
|
+
try {
|
|
57
|
+
runStore = RunStore.open(dbPath);
|
|
58
|
+
if (!runStore.stopForResume('Stopped by user'))
|
|
59
|
+
authoritativeStatus = readRunStatus(dbPath);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// The compatibility report remains the fallback when SQLite is corrupt or locked.
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
try {
|
|
66
|
+
runStore?.close();
|
|
67
|
+
}
|
|
68
|
+
catch { /* best effort */ }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (authoritativeStatus) {
|
|
72
|
+
if (fs.existsSync(reportPath)) {
|
|
73
|
+
try {
|
|
74
|
+
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
75
|
+
report.run_status = authoritativeStatus;
|
|
76
|
+
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
77
|
+
}
|
|
78
|
+
catch { /* SQLite remains authoritative */ }
|
|
79
|
+
}
|
|
80
|
+
return authoritativeStatus;
|
|
53
81
|
}
|
|
82
|
+
if (!fs.existsSync(reportPath))
|
|
83
|
+
return 'stopped';
|
|
54
84
|
try {
|
|
55
85
|
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
56
86
|
const stageStatus = { ...(report.stage_status ?? {}) };
|
|
57
87
|
for (const [stage, status] of Object.entries(stageStatus)) {
|
|
58
88
|
if (status === 'running') {
|
|
59
|
-
stageStatus[stage] = '
|
|
89
|
+
stageStatus[stage] = 'stopped';
|
|
60
90
|
}
|
|
61
91
|
}
|
|
62
92
|
report.stage_status = stageStatus;
|
|
@@ -67,6 +97,7 @@ function markArtifactStopped(job) {
|
|
|
67
97
|
catch {
|
|
68
98
|
// Best-effort cleanup so stopping a job never fails because its report is corrupt.
|
|
69
99
|
}
|
|
100
|
+
return 'stopped';
|
|
70
101
|
}
|
|
71
102
|
export async function startDetachedRun(command, options = {}) {
|
|
72
103
|
const outputFormat = command.outputFormat ?? 'jsonl';
|
|
@@ -184,10 +215,10 @@ export async function stopManagedJob(projectRoot, jobId, options = {}) {
|
|
|
184
215
|
throw new Error(`refusing to stop pid ${pid}: command does not match AutoVPN job`);
|
|
185
216
|
}
|
|
186
217
|
await terminateProcessGroup(pid, options);
|
|
187
|
-
markArtifactStopped(job);
|
|
188
|
-
job.status = 'stopped';
|
|
218
|
+
const artifactStatus = markArtifactStopped(job);
|
|
219
|
+
job.status = artifactStatus === 'success' || artifactStatus === 'failed' ? artifactStatus : 'stopped';
|
|
189
220
|
job.finished_at = options.now?.() ?? new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00');
|
|
190
|
-
job.exit_code = 1;
|
|
221
|
+
job.exit_code = job.status === 'success' ? 0 : 1;
|
|
191
222
|
job.signal = 'SIGTERM';
|
|
192
223
|
return store.writeJob(job);
|
|
193
224
|
}
|
package/dist/jobs/read.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { createRequire } from 'node:module';
|
|
4
3
|
import { resolveProfilePath, readOptionValue } from '../runtime/paths.js';
|
|
5
4
|
import { redactText } from '../runtime/redaction.js';
|
|
5
|
+
import { readRunStatus } from '../pipeline/run-store.js';
|
|
6
6
|
function jobsRoot(projectRoot, env = process.env) {
|
|
7
7
|
return path.join(path.dirname(resolveProfilePath(projectRoot, env)), 'jobs');
|
|
8
8
|
}
|
|
@@ -79,29 +79,16 @@ function reconcileFromRunDb(job) {
|
|
|
79
79
|
if (!fs.existsSync(runDbPath)) {
|
|
80
80
|
return undefined;
|
|
81
81
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const db = new sqlite.DatabaseSync(runDbPath);
|
|
86
|
-
try {
|
|
87
|
-
const row = db.prepare('SELECT status FROM runs ORDER BY run_id DESC LIMIT 1').get();
|
|
88
|
-
const runStatus = String(row?.status ?? '');
|
|
89
|
-
if (!['success', 'failed', 'stopped'].includes(runStatus)) {
|
|
90
|
-
return undefined;
|
|
91
|
-
}
|
|
92
|
-
return {
|
|
93
|
-
status: runStatus,
|
|
94
|
-
finished_at: job.finished_at || nowIso(),
|
|
95
|
-
exit_code: runStatus === 'success' ? 0 : 1
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
finally {
|
|
99
|
-
db.close();
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
82
|
+
const storedStatus = readRunStatus(runDbPath);
|
|
83
|
+
const runStatus = storedStatus === 'cancelled' ? 'stopped' : storedStatus;
|
|
84
|
+
if (!runStatus || !['success', 'failed', 'stopped'].includes(runStatus)) {
|
|
103
85
|
return undefined;
|
|
104
86
|
}
|
|
87
|
+
return {
|
|
88
|
+
status: runStatus,
|
|
89
|
+
finished_at: job.finished_at || nowIso(),
|
|
90
|
+
exit_code: runStatus === 'success' ? 0 : 1
|
|
91
|
+
};
|
|
105
92
|
}
|
|
106
93
|
function writeJob(job) {
|
|
107
94
|
const jobFile = String(job.job_file ?? '');
|
|
@@ -2,6 +2,7 @@ import http from 'node:http';
|
|
|
2
2
|
import net from 'node:net';
|
|
3
3
|
import tls from 'node:tls';
|
|
4
4
|
import { openMihomoRuntime as defaultOpenMihomoRuntime } from './proxy-runtime.js';
|
|
5
|
+
import { isTransientNetworkError, retryTransientNetwork } from './network-retry.js';
|
|
5
6
|
const PROVIDER_TARGETS = [
|
|
6
7
|
{ name: 'gemini', url: 'https://gemini.google.com', allowed_hosts: ['gemini.google.com'], negative_phrases: [] },
|
|
7
8
|
{ name: 'chatgpt_ios', url: 'https://ios.chat.openai.com/', allowed_hosts: ['ios.chat.openai.com'], negative_phrases: [] },
|
|
@@ -615,44 +616,45 @@ export async function fetchUrlViaHttpProxy(url, proxyUrl, timeoutSeconds) {
|
|
|
615
616
|
async function checkLinkAvailabilityMihomo(speedResult, config, options) {
|
|
616
617
|
const openRuntime = options.openMihomoRuntime ?? defaultOpenMihomoRuntime;
|
|
617
618
|
const fetchViaProxy = options.fetchUrlViaHttpProxy ?? fetchUrlViaHttpProxy;
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
619
|
+
return retryTransientNetwork(async () => {
|
|
620
|
+
let runtime;
|
|
621
|
+
try {
|
|
622
|
+
runtime = await openRuntime(speedResult.link, {
|
|
623
|
+
runtimePath: options.runtimePath,
|
|
624
|
+
startupWaitSeconds: numberOrDefault(config.startup_wait_seconds, 1),
|
|
625
|
+
env: options.env
|
|
626
|
+
});
|
|
627
|
+
const providerResults = {};
|
|
628
|
+
const timeoutSeconds = Math.max(1, numberOrDefault(config.timeout_seconds, 20));
|
|
629
|
+
for (const target of options.targets) {
|
|
630
|
+
try {
|
|
631
|
+
const response = await retryTransientNetwork(() => fetchViaProxy(target.url, runtime.proxies.http, timeoutSeconds));
|
|
632
|
+
providerResults[target.name] = evaluateProviderResponse(target, {
|
|
633
|
+
final_url: response.final_url,
|
|
634
|
+
status_code: response.status_code,
|
|
635
|
+
title: extractTitle(response.body),
|
|
636
|
+
body: response.body
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
catch (error) {
|
|
640
|
+
if (isTransientNetworkError(error))
|
|
641
|
+
throw error;
|
|
642
|
+
providerResults[target.name] = {
|
|
643
|
+
provider: target.name,
|
|
644
|
+
passed: false,
|
|
645
|
+
reason: 'runtime_error',
|
|
646
|
+
status_code: 0,
|
|
647
|
+
final_url: target.url,
|
|
648
|
+
matched_phrase: error instanceof Error ? error.message : String(error)
|
|
649
|
+
};
|
|
650
|
+
}
|
|
646
651
|
}
|
|
652
|
+
return { speed_result: speedResult, provider_results: providerResults };
|
|
647
653
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
}
|
|
653
|
-
finally {
|
|
654
|
-
await runtime?.close();
|
|
655
|
-
}
|
|
654
|
+
finally {
|
|
655
|
+
await runtime?.close();
|
|
656
|
+
}
|
|
657
|
+
});
|
|
656
658
|
}
|
|
657
659
|
async function checkBatchInNode(input, options) {
|
|
658
660
|
if (input.results.length === 0) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const DEFAULT_MAX_ATTEMPTS = 2;
|
|
2
|
+
const DEFAULT_RETRY_DELAY_MS = 100;
|
|
3
|
+
export function isTransientNetworkError(error) {
|
|
4
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5
|
+
const code = typeof error === 'object' && error !== null && 'code' in error
|
|
6
|
+
? String(error.code ?? '').toUpperCase()
|
|
7
|
+
: '';
|
|
8
|
+
const statusMatch = /(?:unexpected status|failed with status)\s+(\d{3})\b/i.exec(message);
|
|
9
|
+
if (statusMatch) {
|
|
10
|
+
const status = Number(statusMatch[1]);
|
|
11
|
+
return status >= 500 && status <= 599;
|
|
12
|
+
}
|
|
13
|
+
if ([
|
|
14
|
+
'AUTOVPN_INTERNAL_TIMEOUT',
|
|
15
|
+
'ECONNRESET',
|
|
16
|
+
'ETIMEDOUT',
|
|
17
|
+
'EPIPE',
|
|
18
|
+
'ECONNREFUSED',
|
|
19
|
+
'EAI_AGAIN',
|
|
20
|
+
'ENETUNREACH',
|
|
21
|
+
'EHOSTUNREACH',
|
|
22
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
23
|
+
'UND_ERR_HEADERS_TIMEOUT',
|
|
24
|
+
'UND_ERR_BODY_TIMEOUT'
|
|
25
|
+
].includes(code)) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
if (error instanceof TypeError && message.trim().toLowerCase() === 'fetch failed') {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
return /(?:connection|connect|download|response body|tls handshake).*timed out/i.test(message)
|
|
32
|
+
|| /socket hang up|network is unreachable|temporary failure/i.test(message);
|
|
33
|
+
}
|
|
34
|
+
export async function retryTransientNetwork(operation, options = {}) {
|
|
35
|
+
const maxAttempts = Math.max(1, Math.trunc(options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS));
|
|
36
|
+
const delayMs = Math.max(0, Math.trunc(options.delayMs ?? DEFAULT_RETRY_DELAY_MS));
|
|
37
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
38
|
+
let lastError;
|
|
39
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
40
|
+
try {
|
|
41
|
+
return await operation();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
lastError = error;
|
|
45
|
+
if (attempt >= maxAttempts || !isTransientNetworkError(error)) {
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
if (delayMs > 0) {
|
|
49
|
+
await sleep(delayMs * attempt);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
throw lastError;
|
|
54
|
+
}
|