@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.
@@ -424,7 +424,8 @@ export async function fetchSourceLinksWithBackend(input, options = {}) {
424
424
  // so an early-stopped 1000-round run still read as 1000/1000.
425
425
  rounds: result.diagnostics.rounds,
426
426
  rounds_total: result.diagnostics.rounds_total,
427
- platforms: result.diagnostics.platforms
427
+ platforms: result.diagnostics.platforms,
428
+ area_count: result.diagnostics.route_count
428
429
  };
429
430
  }
430
431
  return fetchSourceLinksInNode(input, options);
@@ -651,7 +651,10 @@ export async function runNodePipeline(options, context = {}) {
651
651
  summary.source_counts[result.source_name] = {
652
652
  raw_links: result.links.length,
653
653
  successful_iterations: result.successful_iterations,
654
- failed_iterations: result.failed_iterations
654
+ failed_iterations: result.failed_iterations,
655
+ // Only credential adapters report the frozen area list; URL rows and
656
+ // older adapters keep the pinned three-key object.
657
+ ...(result.area_count ? { area_count: result.area_count } : {})
655
658
  };
656
659
  // A credential source reports its own failures (rejected login, APK not
657
660
  // located) as zero links rather than an exception. Recording that as
@@ -1483,7 +1486,12 @@ export async function resumeNodePipeline(options, context = {}) {
1483
1486
  status: resumeFailed ? 'failed' : 'success',
1484
1487
  ...(resumeFailed ? { error: resumeErrors.join('; ') } : {})
1485
1488
  });
1486
- summary.source_counts[sourceName] = { raw_links: result.links.length, successful_iterations: result.successful_iterations, failed_iterations: result.failed_iterations };
1489
+ summary.source_counts[sourceName] = {
1490
+ raw_links: result.links.length,
1491
+ successful_iterations: result.successful_iterations,
1492
+ failed_iterations: result.failed_iterations,
1493
+ ...(result.area_count ? { area_count: result.area_count } : {})
1494
+ };
1487
1495
  }
1488
1496
  catch (error) {
1489
1497
  runStore.recordSourceProgress(sourceName, { processed: 0, total: 0, status: 'failed', error: errorMessage(error) });
@@ -217,6 +217,16 @@ export async function createAutoVpnServer(options) {
217
217
  writeJson(response, result.ok === false ? 400 : 200, result);
218
218
  return;
219
219
  }
220
+ if (request.method === 'POST' && url.pathname === '/api/accounts/refresh-expiry') {
221
+ const body = await readJsonBody(request);
222
+ const result = await options.runtime.refreshAccountExpiry?.({
223
+ brand: String(body.brand ?? ''),
224
+ limit: Number(body.limit ?? 0),
225
+ concurrency: Number(body.concurrency ?? 0)
226
+ }) ?? { ok: false, error: 'refresh_expiry_unavailable' };
227
+ writeJson(response, result.ok === false ? 400 : 200, result);
228
+ return;
229
+ }
220
230
  if (request.method === 'POST' && url.pathname === '/api/qr') {
221
231
  const body = await readJsonBody(request);
222
232
  const text = String(body.text ?? '');
@@ -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,23 +198,50 @@ 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);
201
+ return foldEventHistory(events, maxEvents);
202
+ }
203
+ catch {
204
+ return [];
205
+ }
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
+ // Only the LAST run in the file decides whether it is still going. A resume
223
+ // appends to the same artifact log, so an earlier run's terminal line would
224
+ // otherwise mark the live resume finished and the page would never follow it.
225
+ let finished = false;
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;
174
229
  }
175
- if (event.type === 'stage' && 'stage' in event && typeof event.stage === 'string')
176
- priorSteps.set(`stage:${event.stage}`, event);
230
+ else if (event.type === 'summary' || event.type === 'run_failed' || event.type === 'finished') {
231
+ finished = true;
232
+ }
233
+ }
234
+ let fresh = false;
235
+ try {
236
+ fresh = Date.now() - fs.statSync(filePath).mtimeMs < 2 * 60 * 1000;
237
+ }
238
+ catch {
239
+ fresh = false;
177
240
  }
178
- return [...priorSteps.values(), ...events.slice(-maxEvents)];
241
+ return { events: foldEventHistory(events), active: !finished && fresh };
179
242
  }
180
243
  catch {
181
- return [];
244
+ return { events: [], active: false };
182
245
  }
183
246
  }
184
247
  function terminalRunStateFromLatestJob(job, nowMs, ttlMs) {
@@ -197,13 +260,10 @@ function terminalRunStateFromArtifact(artifact, nowMs, ttlMs) {
197
260
  if (!['success', 'failed'].includes(status))
198
261
  return undefined;
199
262
  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
- }
263
+ // The run's own files, not the directory: a read-only progress query creates
264
+ // WAL sidecars and touches the directory, which would keep a terminal state
265
+ // alive indefinitely on a host whose page is simply left open.
266
+ const updatedAt = artifactDir ? artifactWorkedAt(artifactDir) : 0;
207
267
  if (!updatedAt || nowMs - updatedAt > ttlMs)
208
268
  return undefined;
209
269
  return status;
@@ -229,6 +289,30 @@ function safeRmDir(dirPath) {
229
289
  // Cleanup is best-effort; serve startup and state loading must continue.
230
290
  }
231
291
  }
292
+ /**
293
+ * Delete run records whose day is older than the retention window.
294
+ *
295
+ * T+1 is the default: the vendor's nightly run books its results after
296
+ * midnight, so a same-day prune would delete the run the operator is about to
297
+ * open. The window is calendar-day based rather than mtime based, so a file
298
+ * touched by a copy or a backup does not survive forever.
299
+ *
300
+ * `AUTOVPN_SERVER_HISTORY_RETENTION_DAYS` keeps its meaning (0 disables), and
301
+ * `AUTOVPN_SERVER_LOG_RETENTION_DAYS` overrides it for the log files alone.
302
+ */
303
+ function retentionCutoffMs(env, days, now) {
304
+ const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
305
+ return start.getTime() - days * 24 * 60 * 60 * 1000;
306
+ }
307
+ function logRetentionMs(env) {
308
+ const raw = String(env.AUTOVPN_SERVER_LOG_RETENTION_DAYS ?? '').trim();
309
+ if (!raw)
310
+ return historyRetentionMs(env);
311
+ const parsed = Number.parseFloat(raw);
312
+ if (!Number.isFinite(parsed) || parsed <= 0)
313
+ return 0;
314
+ return parsed * 24 * 60 * 60 * 1000;
315
+ }
232
316
  function pruneArtifacts(projectRoot, env, cutoffMs, activeArtifactDirs) {
233
317
  const root = resolveArtifactsRoot(projectRoot, env);
234
318
  if (!fs.existsSync(root))
@@ -249,6 +333,53 @@ function pruneArtifacts(projectRoot, env, cutoffMs, activeArtifactDirs) {
249
333
  }
250
334
  }
251
335
  }
336
+ /**
337
+ * Age out daily-run logs and results on the same schedule.
338
+ *
339
+ * `scripts/server/daily-run.sh` appends one log and one result file per tick
340
+ * and never deletes either, so a host that runs nightly accumulates a year of
341
+ * them. The serve process owns both directories and prunes them here rather
342
+ * than adding a second cron entry that can drift.
343
+ */
344
+ function pruneDailyRunRecords(env, cutoffMs) {
345
+ const runtimeRoot = path.dirname(resolveProfilePath('', env));
346
+ // Only the records this job writes. `logs/` also holds the serve unit's own
347
+ // stdout file, which systemd keeps open: unlinking it would leave the daemon
348
+ // writing to a deleted inode and silently lose every later log line.
349
+ const owned = [/^daily-run-.*\.log$/, /^\d{8}-\d{6}\.json$/];
350
+ for (const dirName of ['logs', 'daily-run-results']) {
351
+ const dir = path.join(runtimeRoot, dirName);
352
+ if (!fs.existsSync(dir))
353
+ continue;
354
+ let names;
355
+ try {
356
+ names = fs.readdirSync(dir);
357
+ }
358
+ catch {
359
+ continue;
360
+ }
361
+ for (const name of names) {
362
+ if (!owned.some((pattern) => pattern.test(name)))
363
+ continue;
364
+ const file = path.join(dir, name);
365
+ let stat;
366
+ try {
367
+ stat = fs.statSync(file);
368
+ }
369
+ catch {
370
+ continue;
371
+ }
372
+ if (!stat.isFile() || stat.mtimeMs >= cutoffMs)
373
+ continue;
374
+ try {
375
+ fs.rmSync(file, { force: true });
376
+ }
377
+ catch {
378
+ // Best effort: an open log handle on another platform is not fatal.
379
+ }
380
+ }
381
+ }
382
+ }
252
383
  function pruneJobs(projectRoot, env, cutoffMs) {
253
384
  const root = jobsRoot(projectRoot, env);
254
385
  const activeArtifactDirs = new Set();
@@ -307,32 +438,126 @@ function createHistoryPruner(options) {
307
438
  let lastPrunedAt = 0;
308
439
  return () => {
309
440
  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();
441
+ const now = (options.now ?? (() => new Date()))();
442
+ const nowMs = now.getTime();
443
+ // At most once a day, at the first state load after a day has passed. The
444
+ // cutoff is the start of the day N days back, so everything older than
445
+ // today (T+1) goes and a run that finished late yesterday stays.
314
446
  if (lastPrunedAt && nowMs - lastPrunedAt < 24 * 60 * 60 * 1000)
315
447
  return;
316
448
  lastPrunedAt = nowMs;
317
- const cutoffMs = nowMs - retention;
318
- const activeArtifactDirs = pruneJobs(options.projectRoot, env, cutoffMs);
319
- pruneArtifacts(options.projectRoot, env, cutoffMs, activeArtifactDirs);
449
+ const retentionDays = historyRetentionMs(env) / (24 * 60 * 60 * 1000);
450
+ if (retentionDays > 0) {
451
+ const cutoffMs = retentionCutoffMs(env, retentionDays, now);
452
+ const activeArtifactDirs = pruneJobs(options.projectRoot, env, cutoffMs);
453
+ pruneArtifacts(options.projectRoot, env, cutoffMs, activeArtifactDirs);
454
+ }
455
+ // Logs carry their own window, so a host can keep a month of run records
456
+ // and only a day of stdout without a second cleanup entry to drift.
457
+ const logDays = logRetentionMs(env) / (24 * 60 * 60 * 1000);
458
+ if (logDays > 0) {
459
+ pruneDailyRunRecords(env, retentionCutoffMs(env, logDays, now));
460
+ }
320
461
  };
321
462
  }
322
463
  export function createServerRuntime(options) {
323
464
  let runState = 'idle';
324
465
  let activeJobId = '';
325
466
  let unsubscribeLogs;
467
+ let unsubscribeArtifactLogs;
468
+ let followedArtifactDir = '';
469
+ let expiryRefreshInFlight = false;
326
470
  const pruneHistory = createHistoryPruner(options);
327
471
  const subscribers = new Set();
328
472
  const startDetachedRun = options.startDetachedRun ?? defaultStartDetachedRun;
329
473
  const startDetachedRetry = options.startDetachedRetry ?? defaultStartDetachedRetry;
330
474
  const stopManagedJob = options.stopManagedJob ?? defaultStopManagedJob;
331
475
  const followLog = options.followLog ?? defaultFollowLog;
332
- function publish(event) {
476
+ const publish = (event) => {
333
477
  for (const subscriber of subscribers) {
334
478
  subscriber(event);
335
479
  }
480
+ };
481
+ /**
482
+ * Follow a plain event log that no job directory owns.
483
+ *
484
+ * A nightly cron run writes `<artifact>/events.jsonl` (the CLI mirrors its
485
+ * stream there), but nothing tells the server to tail it: without this the
486
+ * page stays on whatever the last detached job left and the logs never move.
487
+ * Polling a file is enough here — the writer appends a line at a time and the
488
+ * page needs sub-second freshness, not inotify precision.
489
+ */
490
+ function followArtifactEvents(artifactDir) {
491
+ if (followedArtifactDir === artifactDir && unsubscribeArtifactLogs) {
492
+ return;
493
+ }
494
+ unsubscribeArtifactLogs?.();
495
+ followedArtifactDir = artifactDir;
496
+ const filePath = path.join(artifactDir, 'events.jsonl');
497
+ let cancelled = false;
498
+ let offset = 0;
499
+ let pending = '';
500
+ const timer = setInterval(() => {
501
+ if (cancelled)
502
+ return;
503
+ let chunk = '';
504
+ try {
505
+ const stat = fs.statSync(filePath);
506
+ if (stat.size <= offset) {
507
+ if (stat.size < offset)
508
+ offset = 0;
509
+ return;
510
+ }
511
+ const length = stat.size - offset;
512
+ const buffer = Buffer.alloc(length);
513
+ const fd = fs.openSync(filePath, 'r');
514
+ try {
515
+ fs.readSync(fd, buffer, 0, length, offset);
516
+ }
517
+ finally {
518
+ fs.closeSync(fd);
519
+ }
520
+ offset = stat.size;
521
+ chunk = pending + buffer.toString('utf8');
522
+ }
523
+ catch {
524
+ return;
525
+ }
526
+ const lines = chunk.split(/\r?\n/);
527
+ pending = lines.pop() ?? '';
528
+ let terminal = false;
529
+ for (const line of lines) {
530
+ const event = parseJsonLine(line);
531
+ if (!event)
532
+ continue;
533
+ const nextRunState = eventRunState(event);
534
+ if (nextRunState) {
535
+ runState = nextRunState;
536
+ terminal = true;
537
+ }
538
+ // Same contract as the job follower: a result line replayed after a
539
+ // page load must carry absolute database totals, or the renderer adds
540
+ // it on top of the snapshot it already hydrated and doubles the count.
541
+ const record = event;
542
+ const metricEvent = ['extract_iteration', 'extract_source_completed', 'speedtest_result', 'availability_link_result'].includes(String(record.type));
543
+ const progress = metricEvent ? readRunProgress(path.join(artifactDir, 'run.db')) : undefined;
544
+ publish(progress ? { ...record, progress_snapshot: { ...progress, artifact_dir: artifactDir } } : event);
545
+ if (nextRunState)
546
+ publish({ type: 'server_state', run_state: runState });
547
+ }
548
+ // The run is over: stop polling instead of stat-ing a finished file every
549
+ // 800ms for the life of the server. A later run follows its own artifact.
550
+ if (terminal) {
551
+ unsubscribeArtifactLogs?.();
552
+ unsubscribeArtifactLogs = undefined;
553
+ followedArtifactDir = '';
554
+ }
555
+ }, 800);
556
+ timer.unref?.();
557
+ unsubscribeArtifactLogs = () => {
558
+ cancelled = true;
559
+ clearInterval(timer);
560
+ };
336
561
  }
337
562
  function followJob(jobId) {
338
563
  let cancelled = false;
@@ -383,10 +608,23 @@ export function createServerRuntime(options) {
383
608
  });
384
609
  }
385
610
  function restoreRunState(artifact) {
386
- if (runState !== 'idle') {
611
+ const env = options.env ?? process.env;
612
+ // A terminal state is a report about the *previous* run, not a latch. A new
613
+ // artifact whose log is still growing is a new run, and it must be followed
614
+ // even though the last one ended in success — otherwise the page stays
615
+ // frozen on yesterday's outcome until the server restarts.
616
+ const artifactDir = String(artifact?.artifact_dir ?? '');
617
+ const liveArtifact = artifactDir && artifactEventLog(artifactDir).active ? artifactDir : '';
618
+ if (runState !== 'idle' && !liveArtifact) {
619
+ return;
620
+ }
621
+ if (liveArtifact && runState !== 'running' && runState !== 'stopping') {
622
+ unsubscribeLogs?.();
623
+ activeJobId = '';
624
+ runState = 'running';
625
+ followArtifactEvents(liveArtifact);
387
626
  return;
388
627
  }
389
- const env = options.env ?? process.env;
390
628
  const job = latestJob(options.projectRoot, env);
391
629
  if (job && ['running', 'stopping'].includes(String(job.status ?? ''))) {
392
630
  runState = String(job.status);
@@ -403,6 +641,14 @@ export function createServerRuntime(options) {
403
641
  ?? 'idle';
404
642
  if (runState !== 'running' && runState !== 'stopping') {
405
643
  activeJobId = '';
644
+ // A scheduled run has no job to follow, but its artifact log may still
645
+ // be growing. Restoring that follow is what makes the page live again
646
+ // after the server restarts mid-run.
647
+ const artifactDir = String(artifact?.artifact_dir ?? '');
648
+ if (artifactDir && artifactEventLog(artifactDir).active) {
649
+ runState = 'running';
650
+ followArtifactEvents(artifactDir);
651
+ }
406
652
  }
407
653
  }
408
654
  return {
@@ -412,6 +658,12 @@ export function createServerRuntime(options) {
412
658
  restoreRunState(artifact);
413
659
  const retries = artifactList(options.projectRoot, options.env ?? process.env);
414
660
  const job = latestJob(options.projectRoot, options.env ?? process.env);
661
+ // The artifact's own mirror wins when it exists: a scheduled run has no
662
+ // job, and replaying an older job's log put four finished stages back to
663
+ // 运行中 on the results page. A stale artifact log is still the right
664
+ // history — it is this artifact's, not another run's — so only its
665
+ // `active` flag (which drives live following) is time-bounded.
666
+ const artifactLog = artifactEventLog(String(artifact?.artifact_dir ?? ''));
415
667
  return {
416
668
  profile: sanitizeProfileForServer(profilePayload(options.projectRoot, options.env)),
417
669
  runState,
@@ -420,7 +672,7 @@ export function createServerRuntime(options) {
420
672
  activity: Array.isArray(retries.activity) ? retries.activity : [],
421
673
  activityUnavailable: Number(retries.activityUnavailable ?? 0),
422
674
  deployment: (artifact?.deployment ?? {}),
423
- logEvents: latestJobEvents(job)
675
+ logEvents: artifactLog.events.length ? artifactLog.events : latestJobEvents(job)
424
676
  };
425
677
  },
426
678
  async saveProfile(profile) {
@@ -504,6 +756,51 @@ export function createServerRuntime(options) {
504
756
  store.close();
505
757
  }
506
758
  },
759
+ /**
760
+ * Learn every pooled account's membership date, then retire the lapsed and
761
+ * non-member rows.
762
+ *
763
+ * The worker is imported lazily: it pulls in the whole credential pipeline,
764
+ * and `serve` must start on an install whose dist predates it.
765
+ */
766
+ async refreshAccountExpiry(input = {}) {
767
+ let worker;
768
+ try {
769
+ const module = await import('../pipeline/credential-source.js');
770
+ worker = typeof module.refreshAccountExpiry === 'function'
771
+ ? module.refreshAccountExpiry
772
+ : undefined;
773
+ }
774
+ catch {
775
+ worker = undefined;
776
+ }
777
+ if (!worker)
778
+ return { ok: false, error: 'refresh_expiry_unavailable' };
779
+ // One sweep at a time: it logs in to every active row, and two overlapping
780
+ // sweeps would double the vendor load and race on the same rows.
781
+ if (expiryRefreshInFlight)
782
+ return { ok: false, error: 'refresh_expiry_in_progress' };
783
+ expiryRefreshInFlight = true;
784
+ const store = new AccountStore({ env: options.env, now: options.now });
785
+ try {
786
+ const outcome = await worker({
787
+ brand: String(input.brand ?? '').trim() || undefined,
788
+ limit: Math.max(0, Math.trunc(Number(input.limit ?? 0)) || 0),
789
+ concurrency: Math.max(1, Math.trunc(Number(input.concurrency ?? 3)) || 3),
790
+ env: options.env,
791
+ store,
792
+ now: options.now
793
+ });
794
+ return { ok: true, ...outcome, stats: store.stats() };
795
+ }
796
+ catch (error) {
797
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
798
+ }
799
+ finally {
800
+ expiryRefreshInFlight = false;
801
+ store.close();
802
+ }
803
+ },
507
804
  async startRun(runOptions = {}) {
508
805
  if (runState === 'running' || runState === 'stopping') {
509
806
  return { ok: false, error: 'run_already_active' };
@@ -529,6 +826,7 @@ export function createServerRuntime(options) {
529
826
  throw error;
530
827
  }
531
828
  activeJobId = String(job.job_id ?? '');
829
+ unsubscribeArtifactLogs?.();
532
830
  followJob(activeJobId);
533
831
  return { ok: true, runId: activeJobId, job_id: activeJobId, status: job.status ?? 'running' };
534
832
  },
@@ -552,11 +850,17 @@ export function createServerRuntime(options) {
552
850
  cwd: options.projectRoot
553
851
  });
554
852
  activeJobId = String(job.job_id ?? '');
853
+ unsubscribeArtifactLogs?.();
555
854
  followJob(activeJobId);
556
855
  return { ok: true, runId: activeJobId, job_id: activeJobId, status: job.status ?? 'running' };
557
856
  },
558
857
  async stopRun() {
559
858
  if (runState !== 'running' || !activeJobId) {
859
+ // 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.
861
+ unsubscribeArtifactLogs?.();
862
+ unsubscribeArtifactLogs = undefined;
863
+ followedArtifactDir = '';
560
864
  return { ok: true, requested: false, run_state: runState };
561
865
  }
562
866
  runState = 'stopping';
@@ -583,6 +887,7 @@ export function createServerRuntime(options) {
583
887
  },
584
888
  async close() {
585
889
  unsubscribeLogs?.();
890
+ unsubscribeArtifactLogs?.();
586
891
  if (!activeJobId) {
587
892
  const artifact = normalizeLatestArtifact(options.projectRoot, options.env ?? process.env);
588
893
  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({