@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.
- 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 +293 -68
- 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 +327 -33
- 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 +154 -28
- package/dist/web/renderer/workspace.css +50 -9
- package/package.json +1 -1
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,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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|