@swimmingliu/autovpn 2.2.1 → 2.2.2

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.
@@ -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
- const leftStat = fs.statSync(left);
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) => fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs);
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(fs.statSync(artifactDir).mtimeMs).toISOString().replace('Z', '')
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
- .sort((left, right) => fs.statSync(right.artifactDir).mtimeMs - fs.statSync(left.artifactDir).mtimeMs);
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
- pushEvent(queue, { type: 'resume_latest_state', artifact_dir: artifactDir });
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) => pushEvent(queue, 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) => pushEvent(queue, 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) => pushEvent(queue, 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) => pushEvent(queue, 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} failed=${result.failed}`);
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() {
@@ -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: wasCredential ? positiveInteger(config.max_iterations, 200) : 200,
98
- plateau_limit: wasCredential ? positiveInteger(config.plateau_limit, 50) : 50,
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 (!wasCredential) {
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);