@swimmingliu/autovpn 2.2.1 → 2.2.3
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/artifacts/list.js +37 -7
- package/dist/backend/node-backend.js +90 -6
- package/dist/cli/accounts-commands.js +2 -1
- package/dist/config/profile.js +37 -3
- package/dist/pipeline/brand-cache.js +22 -0
- package/dist/pipeline/credential-source.js +350 -73
- package/dist/pipeline/extract.js +2 -1
- package/dist/pipeline/orchestrator.js +10 -2
- package/dist/server/http.js +10 -0
- package/dist/server/runtime.js +337 -32
- package/dist/server/web-adapter.js +4 -0
- package/dist/web/renderer/analytics.bundle.js +9 -9
- package/dist/web/renderer/app.js +72 -2
- package/dist/web/renderer/i18n.js +1 -1
- package/dist/web/renderer/icons.js +1 -0
- package/dist/web/renderer/styles.css +2 -2
- package/dist/web/renderer/views.js +157 -28
- package/dist/web/renderer/workspace.css +50 -9
- package/package.json +1 -1
package/dist/artifacts/list.js
CHANGED
|
@@ -56,6 +56,38 @@ function restoreArtifactCounts(artifactDir, counts = {}) {
|
|
|
56
56
|
}
|
|
57
57
|
return restored;
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* When this artifact last did work, newest first.
|
|
61
|
+
*
|
|
62
|
+
* The directory's own mtime is not that: `run.db` is WAL-mode, and even a
|
|
63
|
+
* read-only open creates and removes `-wal`/`-shm` beside it, which touches the
|
|
64
|
+
* directory. The server reads every artifact's progress on each state load, so
|
|
65
|
+
* an abandoned run's directory looked newer than the run that actually finished
|
|
66
|
+
* after it — the page then showed the abandoned run's `running` stages forever.
|
|
67
|
+
*
|
|
68
|
+
* The run's own files carry the real timestamp. `run.db` is written throughout,
|
|
69
|
+
* the report at every stage transition; the directory mtime is only a fallback
|
|
70
|
+
* for an artifact that has neither.
|
|
71
|
+
*/
|
|
72
|
+
export function artifactWorkedAt(artifactDir) {
|
|
73
|
+
let newest = 0;
|
|
74
|
+
for (const name of ['run.db', 'pipeline_report.json', 'events.jsonl']) {
|
|
75
|
+
try {
|
|
76
|
+
newest = Math.max(newest, fs.statSync(path.join(artifactDir, name)).mtimeMs);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Missing file: the others still date the run.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (newest)
|
|
83
|
+
return newest;
|
|
84
|
+
try {
|
|
85
|
+
return fs.statSync(artifactDir).mtimeMs;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
59
91
|
function latestArtifactDir(artifactsRoot) {
|
|
60
92
|
if (!fs.existsSync(artifactsRoot)) {
|
|
61
93
|
return '';
|
|
@@ -63,11 +95,8 @@ function latestArtifactDir(artifactsRoot) {
|
|
|
63
95
|
const candidates = fs.readdirSync(artifactsRoot)
|
|
64
96
|
.map((name) => path.join(artifactsRoot, name))
|
|
65
97
|
.filter((item) => fs.statSync(item).isDirectory())
|
|
66
|
-
.sort((left, right) =>
|
|
67
|
-
|
|
68
|
-
const rightStat = fs.statSync(right);
|
|
69
|
-
return rightStat.mtimeMs - leftStat.mtimeMs || path.basename(right).localeCompare(path.basename(left));
|
|
70
|
-
});
|
|
98
|
+
.sort((left, right) => (artifactWorkedAt(right) - artifactWorkedAt(left)
|
|
99
|
+
|| path.basename(right).localeCompare(path.basename(left))));
|
|
71
100
|
return candidates[0] ?? '';
|
|
72
101
|
}
|
|
73
102
|
export function artifactLatest(projectRoot, env = process.env) {
|
|
@@ -126,7 +155,8 @@ export function artifactList(projectRoot, env = process.env) {
|
|
|
126
155
|
const directories = fs.readdirSync(artifactsRoot)
|
|
127
156
|
.map((name) => path.join(artifactsRoot, name))
|
|
128
157
|
.filter((item) => fs.statSync(item).isDirectory() && isArtifactDir(item))
|
|
129
|
-
.sort((left, right) =>
|
|
158
|
+
.sort((left, right) => (artifactWorkedAt(right) - artifactWorkedAt(left)
|
|
159
|
+
|| path.basename(right).localeCompare(path.basename(left))));
|
|
130
160
|
const items = directories.slice(0, 20)
|
|
131
161
|
.map((artifactDir) => {
|
|
132
162
|
const report = loadJson(path.join(artifactDir, 'pipeline_report.json'));
|
|
@@ -140,7 +170,7 @@ export function artifactList(projectRoot, env = process.env) {
|
|
|
140
170
|
source_counts: report.source_counts ?? {},
|
|
141
171
|
retry_context: report.retry_context ?? {},
|
|
142
172
|
retryable_stages: retryableStages(artifactDir, stageStatus),
|
|
143
|
-
updated_at: new Date(
|
|
173
|
+
updated_at: new Date(artifactWorkedAt(artifactDir)).toISOString().replace('Z', '')
|
|
144
174
|
};
|
|
145
175
|
});
|
|
146
176
|
// Retry choices stay bounded; the calendar aggregates every retained run,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { artifactWorkedAt } from '../artifacts/list.js';
|
|
3
4
|
import { resumeNodePipeline, retryNodePipelineStage, runNodePipeline } from '../pipeline/orchestrator.js';
|
|
4
5
|
import { resolveArtifactsRoot } from '../runtime/paths.js';
|
|
5
6
|
import { readLatestStageStatuses, readRunStatus } from '../pipeline/run-store.js';
|
|
@@ -19,7 +20,9 @@ function latestIncompleteRunArtifact(projectRoot, env) {
|
|
|
19
20
|
return { artifactDir, dbPath };
|
|
20
21
|
})
|
|
21
22
|
.filter((candidate) => fs.existsSync(candidate.dbPath))
|
|
22
|
-
|
|
23
|
+
// The run's own files date it; the directory mtime moves whenever a
|
|
24
|
+
// read-only progress query creates WAL sidecars beside `run.db`.
|
|
25
|
+
.sort((left, right) => artifactWorkedAt(right.artifactDir) - artifactWorkedAt(left.artifactDir));
|
|
23
26
|
for (const candidate of candidates) {
|
|
24
27
|
const runStatus = readRunStatus(candidate.dbPath);
|
|
25
28
|
if (!runStatus || ['success', 'failed', 'stopped', 'cancelled'].includes(runStatus)) {
|
|
@@ -47,6 +50,66 @@ function pushEvent(queue, event) {
|
|
|
47
50
|
const wake = queue.wake.shift();
|
|
48
51
|
wake?.();
|
|
49
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Mirror one run's events into its own artifact directory.
|
|
55
|
+
*
|
|
56
|
+
* A run started by cron or by a plain `autovpn run` has no job directory, so
|
|
57
|
+
* its event stream existed only on stdout and the served UI had nothing to
|
|
58
|
+
* follow: the logs page showed the last detached job's stale lines and never
|
|
59
|
+
* refreshed. Writing `<artifact>/events.jsonl` next to the artifact makes the
|
|
60
|
+
* run self-describing, so the server can tail it, a page reload can replay it,
|
|
61
|
+
* and log retention can prune it with the rest of the run's records.
|
|
62
|
+
*
|
|
63
|
+
* The file is opened lazily on `run_started`, the first event that names the
|
|
64
|
+
* artifact directory. When the caller already asked for that exact path via
|
|
65
|
+
* `--event-log`, the orchestrator is writing it and the mirror stays out of the
|
|
66
|
+
* way rather than appending every line twice.
|
|
67
|
+
*/
|
|
68
|
+
function createArtifactEventMirror(explicitEventLog, knownArtifactDir) {
|
|
69
|
+
let file = '';
|
|
70
|
+
let disabled = false;
|
|
71
|
+
const open = (artifactDir) => {
|
|
72
|
+
file = path.join(artifactDir, 'events.jsonl');
|
|
73
|
+
if (explicitEventLog && path.resolve(explicitEventLog) === path.resolve(file)) {
|
|
74
|
+
disabled = true;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
if (knownArtifactDir)
|
|
78
|
+
open(knownArtifactDir);
|
|
79
|
+
return {
|
|
80
|
+
write(event) {
|
|
81
|
+
if (disabled)
|
|
82
|
+
return;
|
|
83
|
+
if (!file) {
|
|
84
|
+
const artifactDir = String(event.artifact_dir ?? '');
|
|
85
|
+
// A resumed run emits no `run_started`; its artifact is known up front.
|
|
86
|
+
if (!artifactDir || (event.type !== 'run_started' && event.type !== 'resume_latest_state'))
|
|
87
|
+
return;
|
|
88
|
+
open(artifactDir);
|
|
89
|
+
if (disabled)
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
// The stream carries request URLs and vendor messages, so the mirror
|
|
94
|
+
// gets the same owner-only mode the job logs use.
|
|
95
|
+
fs.appendFileSync(file, `${JSON.stringify(event)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// The mirror is an observability aid; a full disk must not fail a run.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** The artifact a resume session belongs to, or '' when it cannot be read. */
|
|
104
|
+
function resumeArtifactDir(session) {
|
|
105
|
+
try {
|
|
106
|
+
const payload = JSON.parse(fs.readFileSync(path.join(session, 'session.json'), 'utf8'));
|
|
107
|
+
return String(payload.artifact_dir ?? '');
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return '';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
50
113
|
function finishQueue(queue, error) {
|
|
51
114
|
queue.done = true;
|
|
52
115
|
queue.error = error;
|
|
@@ -69,13 +132,17 @@ export class NodeBackend {
|
|
|
69
132
|
}
|
|
70
133
|
async *run(options) {
|
|
71
134
|
const queue = { events: [], wake: [], done: false };
|
|
135
|
+
const mirror = createArtifactEventMirror(options.eventLog);
|
|
72
136
|
const runner = options.resumeLatest
|
|
73
137
|
? async () => {
|
|
74
138
|
const artifactDir = latestIncompleteRunArtifact(options.projectRoot, this.env);
|
|
75
139
|
if (!artifactDir) {
|
|
76
140
|
throw new Error('No incomplete run.db found to resume');
|
|
77
141
|
}
|
|
78
|
-
|
|
142
|
+
const resumeMirror = createArtifactEventMirror(options.eventLog, artifactDir);
|
|
143
|
+
const stateEvent = { type: 'resume_latest_state', artifact_dir: artifactDir };
|
|
144
|
+
resumeMirror.write(stateEvent);
|
|
145
|
+
pushEvent(queue, stateEvent);
|
|
79
146
|
const session = writeResumeLatestSession(artifactDir, options);
|
|
80
147
|
return resumeNodePipeline({
|
|
81
148
|
projectRoot: options.projectRoot,
|
|
@@ -88,12 +155,18 @@ export class NodeBackend {
|
|
|
88
155
|
humanLog: options.humanLog
|
|
89
156
|
}, {
|
|
90
157
|
env: this.env,
|
|
91
|
-
emit: (event) =>
|
|
158
|
+
emit: (event) => {
|
|
159
|
+
resumeMirror.write(event);
|
|
160
|
+
pushEvent(queue, event);
|
|
161
|
+
}
|
|
92
162
|
});
|
|
93
163
|
}
|
|
94
164
|
: async () => runNodePipeline(options, {
|
|
95
165
|
env: this.env,
|
|
96
|
-
emit: (event) =>
|
|
166
|
+
emit: (event) => {
|
|
167
|
+
mirror.write(event);
|
|
168
|
+
pushEvent(queue, event);
|
|
169
|
+
}
|
|
97
170
|
});
|
|
98
171
|
void runner()
|
|
99
172
|
.then(() => finishQueue(queue))
|
|
@@ -111,6 +184,7 @@ export class NodeBackend {
|
|
|
111
184
|
}
|
|
112
185
|
async *retryStage(options) {
|
|
113
186
|
const queue = { events: [], wake: [], done: false };
|
|
187
|
+
const mirror = createArtifactEventMirror(options.eventLog);
|
|
114
188
|
void retryNodePipelineStage({
|
|
115
189
|
projectRoot: options.projectRoot,
|
|
116
190
|
artifactDir: options.artifactDir,
|
|
@@ -120,7 +194,10 @@ export class NodeBackend {
|
|
|
120
194
|
humanLog: options.humanLog
|
|
121
195
|
}, {
|
|
122
196
|
env: this.env,
|
|
123
|
-
emit: (event) =>
|
|
197
|
+
emit: (event) => {
|
|
198
|
+
mirror.write(event);
|
|
199
|
+
pushEvent(queue, event);
|
|
200
|
+
}
|
|
124
201
|
})
|
|
125
202
|
.then(() => finishQueue(queue))
|
|
126
203
|
.catch((error) => finishQueue(queue, error));
|
|
@@ -137,6 +214,10 @@ export class NodeBackend {
|
|
|
137
214
|
}
|
|
138
215
|
async *resume(options) {
|
|
139
216
|
const queue = { events: [], wake: [], done: false };
|
|
217
|
+
// A resumed run emits no `run_started`, so the mirror is seeded from the
|
|
218
|
+
// session's artifact directory: without it the artifact log would keep the
|
|
219
|
+
// interrupted run's `running` stages forever.
|
|
220
|
+
const mirror = createArtifactEventMirror(options.eventLog, resumeArtifactDir(options.session));
|
|
140
221
|
void resumeNodePipeline({
|
|
141
222
|
projectRoot: options.projectRoot,
|
|
142
223
|
mode: options.mode,
|
|
@@ -146,7 +227,10 @@ export class NodeBackend {
|
|
|
146
227
|
humanLog: options.humanLog
|
|
147
228
|
}, {
|
|
148
229
|
env: this.env,
|
|
149
|
-
emit: (event) =>
|
|
230
|
+
emit: (event) => {
|
|
231
|
+
mirror.write(event);
|
|
232
|
+
pushEvent(queue, event);
|
|
233
|
+
}
|
|
150
234
|
})
|
|
151
235
|
.then(() => finishQueue(queue))
|
|
152
236
|
.catch((error) => finishQueue(queue, error));
|
|
@@ -144,7 +144,8 @@ function renderRefreshLine(event) {
|
|
|
144
144
|
}
|
|
145
145
|
function renderRefreshHuman(rows, result) {
|
|
146
146
|
const lines = rows.map(renderRefreshLine);
|
|
147
|
-
lines.push(`checked=${result.checked} updated=${result.updated} expired=${result.expired}
|
|
147
|
+
lines.push(`checked=${result.checked} updated=${result.updated} expired=${result.expired}` +
|
|
148
|
+
` retired=${result.retired ?? 0} failed=${result.failed}`);
|
|
148
149
|
return `${lines.join('\n')}\n`;
|
|
149
150
|
}
|
|
150
151
|
export function accountsTemplate() {
|
package/dist/config/profile.js
CHANGED
|
@@ -67,6 +67,19 @@ function positiveInteger(value, fallback) {
|
|
|
67
67
|
const parsed = Number(value);
|
|
68
68
|
return Number.isFinite(parsed) && parsed >= 1 ? Math.trunc(parsed) : fallback;
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* A lap count as stored, or null when the key is absent or unusable.
|
|
72
|
+
*
|
|
73
|
+
* Null is what keeps a legacy row legacy: the caller must be able to tell "no
|
|
74
|
+
* lap key" from "lap key of 0", because only the former leaves the round
|
|
75
|
+
* policy in charge. `min_laps` legitimately accepts 0.
|
|
76
|
+
*/
|
|
77
|
+
function positiveLapValue(value) {
|
|
78
|
+
if (value === undefined || value === null || value === '')
|
|
79
|
+
return null;
|
|
80
|
+
const parsed = Number(value);
|
|
81
|
+
return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : null;
|
|
82
|
+
}
|
|
70
83
|
/**
|
|
71
84
|
* Migrate the four vendor rows to the final input contract: official site only.
|
|
72
85
|
*
|
|
@@ -86,6 +99,11 @@ export function normalizeOfficialSources(sources) {
|
|
|
86
99
|
continue;
|
|
87
100
|
}
|
|
88
101
|
const wasCredential = config.mode === 'credential';
|
|
102
|
+
// A lap row is already expressed in the unit the operator chose. Rewriting
|
|
103
|
+
// its legacy caps to the round defaults would silently change the budget
|
|
104
|
+
// the drawer just displayed, so those keys pass through verbatim and only
|
|
105
|
+
// the lap keys are normalized.
|
|
106
|
+
const hasLapKey = ['max_laps', 'dry_laps', 'min_laps'].some((key) => positiveLapValue(config[key]) !== null);
|
|
89
107
|
const normalized = {
|
|
90
108
|
...config,
|
|
91
109
|
mode: 'credential',
|
|
@@ -94,8 +112,12 @@ export function normalizeOfficialSources(sources) {
|
|
|
94
112
|
// A round is one cursor step over the fixed area list, not a full sweep
|
|
95
113
|
// of every region. 200 rounds × the default 8 areas / 50 with no new
|
|
96
114
|
// node matches the documented defaults.
|
|
97
|
-
max_iterations:
|
|
98
|
-
|
|
115
|
+
max_iterations: hasLapKey
|
|
116
|
+
? positiveInteger(config.max_iterations, 200)
|
|
117
|
+
: wasCredential ? positiveInteger(config.max_iterations, 200) : 200,
|
|
118
|
+
plateau_limit: hasLapKey
|
|
119
|
+
? positiveInteger(config.plateau_limit, 50)
|
|
120
|
+
: wasCredential ? positiveInteger(config.plateau_limit, 50) : 50,
|
|
99
121
|
areas_per_round: Math.min(64, positiveInteger(config.areas_per_round, 8)),
|
|
100
122
|
area_concurrency: Math.min(16, positiveInteger(config.area_concurrency, 4))
|
|
101
123
|
};
|
|
@@ -111,7 +133,19 @@ export function normalizeOfficialSources(sources) {
|
|
|
111
133
|
delete normalized.backend_brand;
|
|
112
134
|
}
|
|
113
135
|
}
|
|
114
|
-
if (
|
|
136
|
+
if (hasLapKey) {
|
|
137
|
+
const maxLaps = positiveLapValue(config.max_laps);
|
|
138
|
+
const dryLaps = positiveLapValue(config.dry_laps);
|
|
139
|
+
const minLaps = positiveLapValue(config.min_laps);
|
|
140
|
+
if (maxLaps !== null)
|
|
141
|
+
normalized.max_laps = maxLaps;
|
|
142
|
+
if (dryLaps !== null)
|
|
143
|
+
normalized.dry_laps = dryLaps;
|
|
144
|
+
// A floor of 0 is meaningful ("no guaranteed laps"), so it is kept.
|
|
145
|
+
if (minLaps !== null)
|
|
146
|
+
normalized.min_laps = minLaps;
|
|
147
|
+
}
|
|
148
|
+
if (!wasCredential && !hasLapKey) {
|
|
115
149
|
// A legacy URL row's caps describe a different operation; start the
|
|
116
150
|
// cursor flow at the documented scheduled-run default instead. A floor
|
|
117
151
|
// carried over from URL mode is the same mismatch and is dropped with it.
|
|
@@ -98,6 +98,28 @@ export class FingerprintStore {
|
|
|
98
98
|
}
|
|
99
99
|
return payload;
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Stores an existing fingerprint under a different key.
|
|
103
|
+
*
|
|
104
|
+
* Used when a build changed how keys are derived: the entry was written under
|
|
105
|
+
* the old key and is still the only valid fingerprint on the machine. The
|
|
106
|
+
* original `verified_at` is preserved — stamping a new one would promote an
|
|
107
|
+
* entry that is genuinely past its trust window into a trusted one.
|
|
108
|
+
*/
|
|
109
|
+
adopt(brand, payload) {
|
|
110
|
+
const adopted = { ...payload, brand };
|
|
111
|
+
try {
|
|
112
|
+
fs.mkdirSync(this.cacheDir, { recursive: true });
|
|
113
|
+
const target = cachePath(this.cacheDir, brand);
|
|
114
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
115
|
+
fs.writeFileSync(temporary, `${JSON.stringify(adopted, null, 2)}\n`, 'utf8');
|
|
116
|
+
fs.renameSync(temporary, target);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// The cache is an optimisation; a copy failure must not fail the run.
|
|
120
|
+
}
|
|
121
|
+
return adopted;
|
|
122
|
+
}
|
|
101
123
|
/** Records that a cached fingerprint still works, without re-reading the APK. */
|
|
102
124
|
touch(brand) {
|
|
103
125
|
const payload = this.read(brand);
|