@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/pipeline/extract.js
CHANGED
|
@@ -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] = {
|
|
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) });
|
package/dist/server/http.js
CHANGED
|
@@ -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 ?? '');
|
package/dist/server/runtime.js
CHANGED
|
@@ -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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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 === '
|
|
176
|
-
|
|
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
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|