@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.
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { artifactLatest, artifactList } from '../artifacts/list.js';
3
+ import { artifactLatest, artifactList, artifactWorkedAt } from '../artifacts/list.js';
4
4
  import { previewArtifact } from '../artifacts/preview.js';
5
5
  import { profilePayload, saveProfilePayload } from '../config/profile.js';
6
6
  import { startDetachedRetry as defaultStartDetachedRetry, startDetachedRun as defaultStartDetachedRun, stopManagedJob as defaultStopManagedJob } from '../jobs/commands.js';
@@ -155,6 +155,42 @@ function latestJob(projectRoot, env) {
155
155
  return undefined;
156
156
  }
157
157
  }
158
+ /**
159
+ * Fold stage and substep transitions down to their latest state.
160
+ *
161
+ * The terminal guard is what keeps a finished run from reading as 运行中: the
162
+ * tail of a long log is mostly `extract_iteration`, so a fold that only looked
163
+ * at older lines dropped the final `success` of extract/dedupe/speedtest/
164
+ * availability. Folding the whole stream means the last transition always
165
+ * wins unless it would demote a terminal state back to `running`.
166
+ */
167
+ function foldStageEvents(events) {
168
+ const folded = new Map();
169
+ for (const event of events) {
170
+ if (event.type === 'extract_substage' && typeof event.source_name === 'string'
171
+ && typeof event.platform === 'string' && typeof event.substage === 'string') {
172
+ folded.set(['substage', event.source_name, event.platform, event.substage].join(':'), event);
173
+ }
174
+ if (event.type === 'stage' && typeof event.stage === 'string') {
175
+ folded.set(`stage:${event.stage}`, event);
176
+ }
177
+ }
178
+ return [...folded.values()];
179
+ }
180
+ /**
181
+ * Replay history for a page load: folded states first, then the recent tail.
182
+ *
183
+ * Stage and substep transitions are dropped from the tail because their folded
184
+ * copy already carries the final state; keeping both would replay `running`
185
+ * after `success` and undo the fold.
186
+ */
187
+ function foldEventHistory(events, maxEvents = 1000) {
188
+ const folded = foldStageEvents(events);
189
+ const tail = events
190
+ .slice(-maxEvents)
191
+ .filter((event) => event.type !== 'stage' && event.type !== 'extract_substage');
192
+ return [...folded, ...tail];
193
+ }
158
194
  function latestJobEvents(job, maxEvents = 1000) {
159
195
  const eventLog = String(job?.event_log ?? '');
160
196
  if (!eventLog || !fs.existsSync(eventLog))
@@ -162,25 +198,41 @@ function latestJobEvents(job, maxEvents = 1000) {
162
198
  try {
163
199
  const lines = fs.readFileSync(eventLog, 'utf8').split(/\r?\n/).filter(Boolean);
164
200
  const events = lines.map((line) => parseJsonLine(line)).filter(Boolean);
165
- const priorSteps = new Map();
166
- // APK/domain steps occur early. Preserve their latest states even when
167
- // subsequent node logs exceed the bounded text-history window.
168
- for (const event of events.slice(0, -maxEvents)) {
169
- if (!event || typeof event !== 'object' || !('type' in event))
170
- continue;
171
- if (event.type === 'extract_substage' && 'source_name' in event && typeof event.source_name === 'string'
172
- && 'platform' in event && typeof event.platform === 'string' && 'substage' in event && typeof event.substage === 'string') {
173
- priorSteps.set([event.source_name, event.platform, event.substage].join(':'), event);
174
- }
175
- if (event.type === 'stage' && 'stage' in event && typeof event.stage === 'string')
176
- priorSteps.set(`stage:${event.stage}`, event);
177
- }
178
- return [...priorSteps.values(), ...events.slice(-maxEvents)];
201
+ return foldEventHistory(events, maxEvents);
179
202
  }
180
203
  catch {
181
204
  return [];
182
205
  }
183
206
  }
207
+ /**
208
+ * Stage/substep states and log lines for the latest artifact.
209
+ *
210
+ * A cron run has no job directory, so its only record is the mirror the CLI
211
+ * writes beside the artifact. Its log is the authoritative one when present:
212
+ * it covers every run path (foreground, detached, nightly) with one file.
213
+ */
214
+ function artifactEventLog(artifactDir) {
215
+ const filePath = path.join(artifactDir, 'events.jsonl');
216
+ if (!artifactDir || !fs.existsSync(filePath)) {
217
+ return { events: [], active: false };
218
+ }
219
+ try {
220
+ const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/).filter(Boolean);
221
+ const events = lines.map((line) => parseJsonLine(line)).filter(Boolean);
222
+ const finished = events.some((event) => event.type === 'summary' || event.type === 'run_failed' || event.type === 'finished');
223
+ let fresh = false;
224
+ try {
225
+ fresh = Date.now() - fs.statSync(filePath).mtimeMs < 2 * 60 * 1000;
226
+ }
227
+ catch {
228
+ fresh = false;
229
+ }
230
+ return { events: foldEventHistory(events), active: !finished && fresh };
231
+ }
232
+ catch {
233
+ return { events: [], active: false };
234
+ }
235
+ }
184
236
  function terminalRunStateFromLatestJob(job, nowMs, ttlMs) {
185
237
  const status = String(job?.status ?? '');
186
238
  if (status === 'running' || status === 'stopping')
@@ -197,13 +249,10 @@ function terminalRunStateFromArtifact(artifact, nowMs, ttlMs) {
197
249
  if (!['success', 'failed'].includes(status))
198
250
  return undefined;
199
251
  const artifactDir = String(artifact?.artifact_dir ?? '');
200
- let updatedAt = 0;
201
- try {
202
- updatedAt = artifactDir ? fs.statSync(artifactDir).mtimeMs : 0;
203
- }
204
- catch {
205
- updatedAt = 0;
206
- }
252
+ // The run's own files, not the directory: a read-only progress query creates
253
+ // WAL sidecars and touches the directory, which would keep a terminal state
254
+ // alive indefinitely on a host whose page is simply left open.
255
+ const updatedAt = artifactDir ? artifactWorkedAt(artifactDir) : 0;
207
256
  if (!updatedAt || nowMs - updatedAt > ttlMs)
208
257
  return undefined;
209
258
  return status;
@@ -229,6 +278,30 @@ function safeRmDir(dirPath) {
229
278
  // Cleanup is best-effort; serve startup and state loading must continue.
230
279
  }
231
280
  }
281
+ /**
282
+ * Delete run records whose day is older than the retention window.
283
+ *
284
+ * T+1 is the default: the vendor's nightly run books its results after
285
+ * midnight, so a same-day prune would delete the run the operator is about to
286
+ * open. The window is calendar-day based rather than mtime based, so a file
287
+ * touched by a copy or a backup does not survive forever.
288
+ *
289
+ * `AUTOVPN_SERVER_HISTORY_RETENTION_DAYS` keeps its meaning (0 disables), and
290
+ * `AUTOVPN_SERVER_LOG_RETENTION_DAYS` overrides it for the log files alone.
291
+ */
292
+ function retentionCutoffMs(env, days, now) {
293
+ const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
294
+ return start.getTime() - days * 24 * 60 * 60 * 1000;
295
+ }
296
+ function logRetentionMs(env) {
297
+ const raw = String(env.AUTOVPN_SERVER_LOG_RETENTION_DAYS ?? '').trim();
298
+ if (!raw)
299
+ return historyRetentionMs(env);
300
+ const parsed = Number.parseFloat(raw);
301
+ if (!Number.isFinite(parsed) || parsed <= 0)
302
+ return 0;
303
+ return parsed * 24 * 60 * 60 * 1000;
304
+ }
232
305
  function pruneArtifacts(projectRoot, env, cutoffMs, activeArtifactDirs) {
233
306
  const root = resolveArtifactsRoot(projectRoot, env);
234
307
  if (!fs.existsSync(root))
@@ -249,6 +322,53 @@ function pruneArtifacts(projectRoot, env, cutoffMs, activeArtifactDirs) {
249
322
  }
250
323
  }
251
324
  }
325
+ /**
326
+ * Age out daily-run logs and results on the same schedule.
327
+ *
328
+ * `scripts/server/daily-run.sh` appends one log and one result file per tick
329
+ * and never deletes either, so a host that runs nightly accumulates a year of
330
+ * them. The serve process owns both directories and prunes them here rather
331
+ * than adding a second cron entry that can drift.
332
+ */
333
+ function pruneDailyRunRecords(env, cutoffMs) {
334
+ const runtimeRoot = path.dirname(resolveProfilePath('', env));
335
+ // Only the records this job writes. `logs/` also holds the serve unit's own
336
+ // stdout file, which systemd keeps open: unlinking it would leave the daemon
337
+ // writing to a deleted inode and silently lose every later log line.
338
+ const owned = [/^daily-run-.*\.log$/, /^\d{8}-\d{6}\.json$/];
339
+ for (const dirName of ['logs', 'daily-run-results']) {
340
+ const dir = path.join(runtimeRoot, dirName);
341
+ if (!fs.existsSync(dir))
342
+ continue;
343
+ let names;
344
+ try {
345
+ names = fs.readdirSync(dir);
346
+ }
347
+ catch {
348
+ continue;
349
+ }
350
+ for (const name of names) {
351
+ if (!owned.some((pattern) => pattern.test(name)))
352
+ continue;
353
+ const file = path.join(dir, name);
354
+ let stat;
355
+ try {
356
+ stat = fs.statSync(file);
357
+ }
358
+ catch {
359
+ continue;
360
+ }
361
+ if (!stat.isFile() || stat.mtimeMs >= cutoffMs)
362
+ continue;
363
+ try {
364
+ fs.rmSync(file, { force: true });
365
+ }
366
+ catch {
367
+ // Best effort: an open log handle on another platform is not fatal.
368
+ }
369
+ }
370
+ }
371
+ }
252
372
  function pruneJobs(projectRoot, env, cutoffMs) {
253
373
  const root = jobsRoot(projectRoot, env);
254
374
  const activeArtifactDirs = new Set();
@@ -307,32 +427,126 @@ function createHistoryPruner(options) {
307
427
  let lastPrunedAt = 0;
308
428
  return () => {
309
429
  const env = options.env ?? process.env;
310
- const retention = historyRetentionMs(env);
311
- if (retention <= 0)
312
- return;
313
- const nowMs = (options.now ?? (() => new Date()))().getTime();
430
+ const now = (options.now ?? (() => new Date()))();
431
+ const nowMs = now.getTime();
432
+ // At most once a day, at the first state load after a day has passed. The
433
+ // cutoff is the start of the day N days back, so everything older than
434
+ // today (T+1) goes and a run that finished late yesterday stays.
314
435
  if (lastPrunedAt && nowMs - lastPrunedAt < 24 * 60 * 60 * 1000)
315
436
  return;
316
437
  lastPrunedAt = nowMs;
317
- const cutoffMs = nowMs - retention;
318
- const activeArtifactDirs = pruneJobs(options.projectRoot, env, cutoffMs);
319
- pruneArtifacts(options.projectRoot, env, cutoffMs, activeArtifactDirs);
438
+ const retentionDays = historyRetentionMs(env) / (24 * 60 * 60 * 1000);
439
+ if (retentionDays > 0) {
440
+ const cutoffMs = retentionCutoffMs(env, retentionDays, now);
441
+ const activeArtifactDirs = pruneJobs(options.projectRoot, env, cutoffMs);
442
+ pruneArtifacts(options.projectRoot, env, cutoffMs, activeArtifactDirs);
443
+ }
444
+ // Logs carry their own window, so a host can keep a month of run records
445
+ // and only a day of stdout without a second cleanup entry to drift.
446
+ const logDays = logRetentionMs(env) / (24 * 60 * 60 * 1000);
447
+ if (logDays > 0) {
448
+ pruneDailyRunRecords(env, retentionCutoffMs(env, logDays, now));
449
+ }
320
450
  };
321
451
  }
322
452
  export function createServerRuntime(options) {
323
453
  let runState = 'idle';
324
454
  let activeJobId = '';
325
455
  let unsubscribeLogs;
456
+ let unsubscribeArtifactLogs;
457
+ let followedArtifactDir = '';
458
+ let expiryRefreshInFlight = false;
326
459
  const pruneHistory = createHistoryPruner(options);
327
460
  const subscribers = new Set();
328
461
  const startDetachedRun = options.startDetachedRun ?? defaultStartDetachedRun;
329
462
  const startDetachedRetry = options.startDetachedRetry ?? defaultStartDetachedRetry;
330
463
  const stopManagedJob = options.stopManagedJob ?? defaultStopManagedJob;
331
464
  const followLog = options.followLog ?? defaultFollowLog;
332
- function publish(event) {
465
+ const publish = (event) => {
333
466
  for (const subscriber of subscribers) {
334
467
  subscriber(event);
335
468
  }
469
+ };
470
+ /**
471
+ * Follow a plain event log that no job directory owns.
472
+ *
473
+ * A nightly cron run writes `<artifact>/events.jsonl` (the CLI mirrors its
474
+ * stream there), but nothing tells the server to tail it: without this the
475
+ * page stays on whatever the last detached job left and the logs never move.
476
+ * Polling a file is enough here — the writer appends a line at a time and the
477
+ * page needs sub-second freshness, not inotify precision.
478
+ */
479
+ function followArtifactEvents(artifactDir) {
480
+ if (followedArtifactDir === artifactDir && unsubscribeArtifactLogs) {
481
+ return;
482
+ }
483
+ unsubscribeArtifactLogs?.();
484
+ followedArtifactDir = artifactDir;
485
+ const filePath = path.join(artifactDir, 'events.jsonl');
486
+ let cancelled = false;
487
+ let offset = 0;
488
+ let pending = '';
489
+ const timer = setInterval(() => {
490
+ if (cancelled)
491
+ return;
492
+ let chunk = '';
493
+ try {
494
+ const stat = fs.statSync(filePath);
495
+ if (stat.size <= offset) {
496
+ if (stat.size < offset)
497
+ offset = 0;
498
+ return;
499
+ }
500
+ const length = stat.size - offset;
501
+ const buffer = Buffer.alloc(length);
502
+ const fd = fs.openSync(filePath, 'r');
503
+ try {
504
+ fs.readSync(fd, buffer, 0, length, offset);
505
+ }
506
+ finally {
507
+ fs.closeSync(fd);
508
+ }
509
+ offset = stat.size;
510
+ chunk = pending + buffer.toString('utf8');
511
+ }
512
+ catch {
513
+ return;
514
+ }
515
+ const lines = chunk.split(/\r?\n/);
516
+ pending = lines.pop() ?? '';
517
+ let terminal = false;
518
+ for (const line of lines) {
519
+ const event = parseJsonLine(line);
520
+ if (!event)
521
+ continue;
522
+ const nextRunState = eventRunState(event);
523
+ if (nextRunState) {
524
+ runState = nextRunState;
525
+ terminal = true;
526
+ }
527
+ // Same contract as the job follower: a result line replayed after a
528
+ // page load must carry absolute database totals, or the renderer adds
529
+ // it on top of the snapshot it already hydrated and doubles the count.
530
+ const record = event;
531
+ const metricEvent = ['extract_iteration', 'extract_source_completed', 'speedtest_result', 'availability_link_result'].includes(String(record.type));
532
+ const progress = metricEvent ? readRunProgress(path.join(artifactDir, 'run.db')) : undefined;
533
+ publish(progress ? { ...record, progress_snapshot: { ...progress, artifact_dir: artifactDir } } : event);
534
+ if (nextRunState)
535
+ publish({ type: 'server_state', run_state: runState });
536
+ }
537
+ // The run is over: stop polling instead of stat-ing a finished file every
538
+ // 800ms for the life of the server. A later run follows its own artifact.
539
+ if (terminal) {
540
+ unsubscribeArtifactLogs?.();
541
+ unsubscribeArtifactLogs = undefined;
542
+ followedArtifactDir = '';
543
+ }
544
+ }, 800);
545
+ timer.unref?.();
546
+ unsubscribeArtifactLogs = () => {
547
+ cancelled = true;
548
+ clearInterval(timer);
549
+ };
336
550
  }
337
551
  function followJob(jobId) {
338
552
  let cancelled = false;
@@ -383,10 +597,23 @@ export function createServerRuntime(options) {
383
597
  });
384
598
  }
385
599
  function restoreRunState(artifact) {
386
- if (runState !== 'idle') {
600
+ const env = options.env ?? process.env;
601
+ // A terminal state is a report about the *previous* run, not a latch. A new
602
+ // artifact whose log is still growing is a new run, and it must be followed
603
+ // even though the last one ended in success — otherwise the page stays
604
+ // frozen on yesterday's outcome until the server restarts.
605
+ const artifactDir = String(artifact?.artifact_dir ?? '');
606
+ const liveArtifact = artifactDir && artifactEventLog(artifactDir).active ? artifactDir : '';
607
+ if (runState !== 'idle' && !liveArtifact) {
608
+ return;
609
+ }
610
+ if (liveArtifact && runState !== 'running' && runState !== 'stopping') {
611
+ unsubscribeLogs?.();
612
+ activeJobId = '';
613
+ runState = 'running';
614
+ followArtifactEvents(liveArtifact);
387
615
  return;
388
616
  }
389
- const env = options.env ?? process.env;
390
617
  const job = latestJob(options.projectRoot, env);
391
618
  if (job && ['running', 'stopping'].includes(String(job.status ?? ''))) {
392
619
  runState = String(job.status);
@@ -403,6 +630,14 @@ export function createServerRuntime(options) {
403
630
  ?? 'idle';
404
631
  if (runState !== 'running' && runState !== 'stopping') {
405
632
  activeJobId = '';
633
+ // A scheduled run has no job to follow, but its artifact log may still
634
+ // be growing. Restoring that follow is what makes the page live again
635
+ // after the server restarts mid-run.
636
+ const artifactDir = String(artifact?.artifact_dir ?? '');
637
+ if (artifactDir && artifactEventLog(artifactDir).active) {
638
+ runState = 'running';
639
+ followArtifactEvents(artifactDir);
640
+ }
406
641
  }
407
642
  }
408
643
  return {
@@ -412,6 +647,12 @@ export function createServerRuntime(options) {
412
647
  restoreRunState(artifact);
413
648
  const retries = artifactList(options.projectRoot, options.env ?? process.env);
414
649
  const job = latestJob(options.projectRoot, options.env ?? process.env);
650
+ // The artifact's own mirror wins when it exists: a scheduled run has no
651
+ // job, and replaying an older job's log put four finished stages back to
652
+ // 运行中 on the results page. A stale artifact log is still the right
653
+ // history — it is this artifact's, not another run's — so only its
654
+ // `active` flag (which drives live following) is time-bounded.
655
+ const artifactLog = artifactEventLog(String(artifact?.artifact_dir ?? ''));
415
656
  return {
416
657
  profile: sanitizeProfileForServer(profilePayload(options.projectRoot, options.env)),
417
658
  runState,
@@ -420,7 +661,7 @@ export function createServerRuntime(options) {
420
661
  activity: Array.isArray(retries.activity) ? retries.activity : [],
421
662
  activityUnavailable: Number(retries.activityUnavailable ?? 0),
422
663
  deployment: (artifact?.deployment ?? {}),
423
- logEvents: latestJobEvents(job)
664
+ logEvents: artifactLog.events.length ? artifactLog.events : latestJobEvents(job)
424
665
  };
425
666
  },
426
667
  async saveProfile(profile) {
@@ -504,6 +745,51 @@ export function createServerRuntime(options) {
504
745
  store.close();
505
746
  }
506
747
  },
748
+ /**
749
+ * Learn every pooled account's membership date, then retire the lapsed and
750
+ * non-member rows.
751
+ *
752
+ * The worker is imported lazily: it pulls in the whole credential pipeline,
753
+ * and `serve` must start on an install whose dist predates it.
754
+ */
755
+ async refreshAccountExpiry(input = {}) {
756
+ let worker;
757
+ try {
758
+ const module = await import('../pipeline/credential-source.js');
759
+ worker = typeof module.refreshAccountExpiry === 'function'
760
+ ? module.refreshAccountExpiry
761
+ : undefined;
762
+ }
763
+ catch {
764
+ worker = undefined;
765
+ }
766
+ if (!worker)
767
+ return { ok: false, error: 'refresh_expiry_unavailable' };
768
+ // One sweep at a time: it logs in to every active row, and two overlapping
769
+ // sweeps would double the vendor load and race on the same rows.
770
+ if (expiryRefreshInFlight)
771
+ return { ok: false, error: 'refresh_expiry_in_progress' };
772
+ expiryRefreshInFlight = true;
773
+ const store = new AccountStore({ env: options.env, now: options.now });
774
+ try {
775
+ const outcome = await worker({
776
+ brand: String(input.brand ?? '').trim() || undefined,
777
+ limit: Math.max(0, Math.trunc(Number(input.limit ?? 0)) || 0),
778
+ concurrency: Math.max(1, Math.trunc(Number(input.concurrency ?? 3)) || 3),
779
+ env: options.env,
780
+ store,
781
+ now: options.now
782
+ });
783
+ return { ok: true, ...outcome, stats: store.stats() };
784
+ }
785
+ catch (error) {
786
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
787
+ }
788
+ finally {
789
+ expiryRefreshInFlight = false;
790
+ store.close();
791
+ }
792
+ },
507
793
  async startRun(runOptions = {}) {
508
794
  if (runState === 'running' || runState === 'stopping') {
509
795
  return { ok: false, error: 'run_already_active' };
@@ -529,6 +815,7 @@ export function createServerRuntime(options) {
529
815
  throw error;
530
816
  }
531
817
  activeJobId = String(job.job_id ?? '');
818
+ unsubscribeArtifactLogs?.();
532
819
  followJob(activeJobId);
533
820
  return { ok: true, runId: activeJobId, job_id: activeJobId, status: job.status ?? 'running' };
534
821
  },
@@ -552,11 +839,17 @@ export function createServerRuntime(options) {
552
839
  cwd: options.projectRoot
553
840
  });
554
841
  activeJobId = String(job.job_id ?? '');
842
+ unsubscribeArtifactLogs?.();
555
843
  followJob(activeJobId);
556
844
  return { ok: true, runId: activeJobId, job_id: activeJobId, status: job.status ?? 'running' };
557
845
  },
558
846
  async stopRun() {
559
847
  if (runState !== 'running' || !activeJobId) {
848
+ // 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.
850
+ unsubscribeArtifactLogs?.();
851
+ unsubscribeArtifactLogs = undefined;
852
+ followedArtifactDir = '';
560
853
  return { ok: true, requested: false, run_state: runState };
561
854
  }
562
855
  runState = 'stopping';
@@ -583,6 +876,7 @@ export function createServerRuntime(options) {
583
876
  },
584
877
  async close() {
585
878
  unsubscribeLogs?.();
879
+ unsubscribeArtifactLogs?.();
586
880
  if (!activeJobId) {
587
881
  const artifact = normalizeLatestArtifact(options.projectRoot, options.env ?? process.env);
588
882
  restoreRunState(artifact);
@@ -185,6 +185,10 @@ export function renderWebAdapterScript(options = {}) {
185
185
  )
186
186
  }),
187
187
  accountsTemplate: async () => ({ ok: true, content: ${JSON.stringify(accountsTemplate())} }),
188
+ refreshAccountExpiry: async (payload = {}) => request('/api/accounts/refresh-expiry', {
189
+ method: 'POST',
190
+ body: JSON.stringify(payload || {})
191
+ }),
188
192
  runPipeline: async (options = {}) => request('/api/runs', {
189
193
  method: 'POST',
190
194
  body: JSON.stringify({