@sovovs/bycli 2.1.40 → 2.1.42
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/cli-manifest.json +10 -2
- package/clis/weixin/_wechat/article-artifact.js +55 -0
- package/clis/weixin/_wechat/article-identity.js +27 -0
- package/clis/weixin/_wechat/publish-analysis.js +8 -4
- package/clis/weixin/_wechat/publish-download.js +3 -1
- package/clis/weixin/download-publish-data.js +20 -0
- package/clis/weixin/download.js +15 -2
- package/dist/src/adapter-coordination.d.ts +26 -0
- package/dist/src/adapter-coordination.js +183 -0
- package/dist/src/adapter-coordination.test.d.ts +1 -0
- package/dist/src/adapter-execution-context.d.ts +6 -0
- package/dist/src/adapter-execution-context.js +8 -0
- package/dist/src/adapter-scheduler.d.ts +86 -0
- package/dist/src/adapter-scheduler.js +349 -0
- package/dist/src/adapter-scheduler.test.d.ts +1 -0
- package/dist/src/browser/daemon-client.d.ts +11 -0
- package/dist/src/browser/daemon-client.js +53 -1
- package/dist/src/browser/extension-capabilities.d.ts +1 -0
- package/dist/src/browser/extension-capabilities.js +18 -5
- package/dist/src/browser/page.d.ts +2 -1
- package/dist/src/browser/page.js +3 -0
- package/dist/src/build-manifest.js +1 -0
- package/dist/src/cli-argv-preprocess.d.ts +3 -0
- package/dist/src/cli-argv-preprocess.js +4 -0
- package/dist/src/commanderAdapter.js +11 -0
- package/dist/src/daemon.js +118 -0
- package/dist/src/discovery.js +1 -0
- package/dist/src/download/article-download.d.ts +2 -0
- package/dist/src/download/article-download.js +30 -4
- package/dist/src/errors.d.ts +3 -0
- package/dist/src/errors.js +5 -0
- package/dist/src/execution.d.ts +2 -0
- package/dist/src/execution.js +172 -105
- package/dist/src/help.d.ts +1 -0
- package/dist/src/help.js +40 -0
- package/dist/src/manifest-types.d.ts +4 -0
- package/dist/src/registry.d.ts +6 -0
- package/dist/src/registry.js +23 -0
- package/dist/src/serialization.d.ts +1 -0
- package/dist/src/serialization.js +1 -0
- package/dist/src/types.d.ts +2 -0
- package/package.json +3 -2
package/dist/src/daemon.js
CHANGED
|
@@ -39,6 +39,7 @@ import { recordExtensionVersion } from './update-check.js';
|
|
|
39
39
|
import { EXTENSION_CAPABILITY_MISSING_ERROR_CODE, EXTENSION_CAPABILITY_MISSING_HTTP_STATUS, extensionCapabilityHint, missingRequiredExtensionCapability, normalizeExtensionCapabilities, } from './browser/extension-capabilities.js';
|
|
40
40
|
import { buildCommandDispatchFailure, buildExtensionDisconnectFailure, getResponseCorsHeaders, } from './daemon-utils.js';
|
|
41
41
|
import { resolveDaemonHost } from './daemon-config.js';
|
|
42
|
+
import { AdapterScheduler, AdapterSchedulerError, } from './adapter-scheduler.js';
|
|
42
43
|
const PORT = parseInt(process.env.BYCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
|
43
44
|
const HOST = resolveDaemonHost();
|
|
44
45
|
const BROWSER_RECOVERY_COMMAND = process.env.BYCLI_BROWSER_RECOVERY_COMMAND?.trim();
|
|
@@ -60,6 +61,9 @@ const logger = createRecorderLogger(LOG_LEVELS.includes(envLogLevel) ? envLogLev
|
|
|
60
61
|
// runner counters surface on GET /metrics and runner logs share the daemon's level.
|
|
61
62
|
setDefaultRunnerObservability(metrics, logger);
|
|
62
63
|
const extensionProfiles = new Map();
|
|
64
|
+
const adapterScheduler = new AdapterScheduler();
|
|
65
|
+
const adapterSchedulerSweep = setInterval(() => adapterScheduler.sweepExpired(), 5_000);
|
|
66
|
+
adapterSchedulerSweep.unref?.();
|
|
63
67
|
const pending = new Map();
|
|
64
68
|
let commandResultUnknownCount = 0;
|
|
65
69
|
const LOG_BUFFER_SIZE = 200;
|
|
@@ -386,6 +390,92 @@ async function handleRequest(req, res) {
|
|
|
386
390
|
}
|
|
387
391
|
return;
|
|
388
392
|
}
|
|
393
|
+
if (req.method === 'POST' && pathname.startsWith('/v1/adapter-leases/')) {
|
|
394
|
+
try {
|
|
395
|
+
const body = JSON.parse(await readBody(req));
|
|
396
|
+
if (pathname === '/v1/adapter-leases/acquire') {
|
|
397
|
+
const request = body;
|
|
398
|
+
let clientGone = false;
|
|
399
|
+
const onClose = () => {
|
|
400
|
+
if (res.writableEnded)
|
|
401
|
+
return;
|
|
402
|
+
clientGone = true;
|
|
403
|
+
adapterScheduler.cancel(request.requestId);
|
|
404
|
+
};
|
|
405
|
+
res.once('close', onClose);
|
|
406
|
+
const lease = await adapterScheduler.acquire(request);
|
|
407
|
+
res.off('close', onClose);
|
|
408
|
+
if (clientGone || res.destroyed) {
|
|
409
|
+
adapterScheduler.release({ ...lease, reason: 'cancelled' });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
jsonResponse(res, 200, { ok: true, data: lease });
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (pathname === '/v1/adapter-leases/heartbeat') {
|
|
416
|
+
const lease = adapterScheduler.heartbeat(body);
|
|
417
|
+
jsonResponse(res, 200, { ok: true, data: lease });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (pathname === '/v1/adapter-leases/release') {
|
|
421
|
+
const released = adapterScheduler.release(body);
|
|
422
|
+
jsonResponse(res, 200, { ok: true, data: { released } });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (pathname === '/v1/adapter-leases/cancel') {
|
|
426
|
+
const requestId = typeof body.requestId === 'string' ? body.requestId : '';
|
|
427
|
+
const cancelled = requestId ? adapterScheduler.cancel(requestId) : false;
|
|
428
|
+
jsonResponse(res, 200, { ok: true, data: { cancelled } });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
jsonResponse(res, 404, { ok: false, errorCode: 'ADAPTER_QUEUE_RESET', error: 'Unknown Adapter lease operation' });
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
if (res.destroyed)
|
|
435
|
+
return;
|
|
436
|
+
const schedulerError = error instanceof AdapterSchedulerError ? error : null;
|
|
437
|
+
const status = schedulerError?.code === 'ADAPTER_QUEUE_TIMEOUT' ? 408
|
|
438
|
+
: schedulerError?.code === 'ADAPTER_LEASE_LOST' ? 409
|
|
439
|
+
: schedulerError?.code === 'ADAPTER_POOL_AUTH_GATE' || schedulerError?.code === 'ADAPTER_POOL_RATE_LIMITED' ? 409
|
|
440
|
+
: 400;
|
|
441
|
+
jsonResponse(res, status, {
|
|
442
|
+
ok: false,
|
|
443
|
+
errorCode: schedulerError?.code ?? 'ADAPTER_QUEUE_RESET',
|
|
444
|
+
error: error instanceof Error ? error.message : 'Adapter scheduler request failed',
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (req.method === 'POST' && pathname.startsWith('/v1/adapter-resources/')) {
|
|
450
|
+
try {
|
|
451
|
+
const body = JSON.parse(await readBody(req));
|
|
452
|
+
const lease = body.lease;
|
|
453
|
+
if (pathname === '/v1/adapter-resources/acquire') {
|
|
454
|
+
const keys = Array.isArray(body.keys) ? body.keys.filter((key) => typeof key === 'string') : [];
|
|
455
|
+
const timeoutMs = typeof body.timeoutMs === 'number' ? body.timeoutMs : 0;
|
|
456
|
+
const grant = await adapterScheduler.acquireResources(lease, keys, timeoutMs);
|
|
457
|
+
jsonResponse(res, 200, { ok: true, data: grant });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (pathname === '/v1/adapter-resources/release') {
|
|
461
|
+
const grantId = typeof body.grantId === 'string' ? body.grantId : '';
|
|
462
|
+
const released = adapterScheduler.releaseResources(lease, grantId);
|
|
463
|
+
jsonResponse(res, 200, { ok: true, data: { released } });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
jsonResponse(res, 404, { ok: false, errorCode: 'ADAPTER_QUEUE_RESET', error: 'Unknown Adapter resource operation' });
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
const schedulerError = error instanceof AdapterSchedulerError ? error : null;
|
|
470
|
+
const status = schedulerError?.code === 'ADAPTER_RESOURCE_TIMEOUT' ? 408 : 409;
|
|
471
|
+
jsonResponse(res, status, {
|
|
472
|
+
ok: false,
|
|
473
|
+
errorCode: schedulerError?.code ?? 'ADAPTER_LEASE_LOST',
|
|
474
|
+
error: error instanceof Error ? error.message : 'Adapter resource request failed',
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
389
479
|
if (req.method === 'GET' && pathname === '/status') {
|
|
390
480
|
const uptime = process.uptime();
|
|
391
481
|
const mem = process.memoryUsage();
|
|
@@ -415,6 +505,8 @@ async function handleRequest(req, res) {
|
|
|
415
505
|
profileDisconnected: route.errorCode === 'profile_disconnected',
|
|
416
506
|
profiles,
|
|
417
507
|
pending: pending.size,
|
|
508
|
+
adapterLeases: adapterScheduler.snapshot(),
|
|
509
|
+
adapterResources: adapterScheduler.resourceSnapshot(),
|
|
418
510
|
commandResultUnknown: commandResultUnknownCount,
|
|
419
511
|
memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
|
|
420
512
|
port: PORT,
|
|
@@ -454,6 +546,30 @@ async function handleRequest(req, res) {
|
|
|
454
546
|
jsonResponse(res, 400, { ok: false, error: 'Missing command id' });
|
|
455
547
|
return;
|
|
456
548
|
}
|
|
549
|
+
const namedAdapterSession = body.surface === 'adapter'
|
|
550
|
+
&& body.siteSession === 'persistent'
|
|
551
|
+
&& typeof body.session === 'string'
|
|
552
|
+
&& /^site:[^:\s]+:[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(body.session);
|
|
553
|
+
if (namedAdapterSession && !body.adapterLease) {
|
|
554
|
+
throw new DaemonCommandFailure('Named Adapter browser commands require an active lease', 'ADAPTER_LEASE_LOST', undefined, 409);
|
|
555
|
+
}
|
|
556
|
+
if (body.adapterLease) {
|
|
557
|
+
try {
|
|
558
|
+
const lease = adapterScheduler.assertLease(body.adapterLease);
|
|
559
|
+
if (body.contextId !== lease.contextId
|
|
560
|
+
|| body.surface !== 'adapter'
|
|
561
|
+
|| body.siteSession !== 'persistent'
|
|
562
|
+
|| body.session !== lease.sessionKey) {
|
|
563
|
+
throw new AdapterSchedulerError('ADAPTER_LEASE_LOST', 'Adapter browser command does not match its lease scope');
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
catch (error) {
|
|
567
|
+
if (error instanceof AdapterSchedulerError) {
|
|
568
|
+
throw new DaemonCommandFailure(error.message, error.code, undefined, 409);
|
|
569
|
+
}
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
457
573
|
const route = resolveExtensionConnection(typeof body.contextId === 'string' ? body.contextId : undefined);
|
|
458
574
|
if (!route.connection) {
|
|
459
575
|
jsonResponse(res, route.errorCode === 'profile_required' ? 409 : 503, {
|
|
@@ -722,6 +838,8 @@ function shutdown() {
|
|
|
722
838
|
p.reject(new Error('Daemon shutting down'));
|
|
723
839
|
}
|
|
724
840
|
pending.clear();
|
|
841
|
+
clearInterval(adapterSchedulerSweep);
|
|
842
|
+
adapterScheduler.reset();
|
|
725
843
|
for (const profile of extensionProfiles.values())
|
|
726
844
|
profile.ws.close();
|
|
727
845
|
httpServer.close();
|
package/dist/src/discovery.js
CHANGED
|
@@ -137,6 +137,7 @@ export async function loadFromManifest(manifestPath, clisDir) {
|
|
|
137
137
|
source: entry.sourceFile ? path.resolve(clisDir, entry.sourceFile) : modulePath,
|
|
138
138
|
navigateBefore: entry.navigateBefore,
|
|
139
139
|
siteSession: entry.siteSession,
|
|
140
|
+
adapterConcurrency: entry.adapterConcurrency,
|
|
140
141
|
_lazy: true,
|
|
141
142
|
_modulePath: modulePath,
|
|
142
143
|
};
|
|
@@ -53,6 +53,8 @@ export interface ArticleDownloadOptions {
|
|
|
53
53
|
stdout?: boolean;
|
|
54
54
|
/** Opt-in hardened Markdown rules used by HTML-focused adapters. */
|
|
55
55
|
secureMarkdown?: boolean;
|
|
56
|
+
/** Lease fencing check invoked immediately before the final Markdown write. */
|
|
57
|
+
beforePublish?: () => Promise<void>;
|
|
56
58
|
}
|
|
57
59
|
export interface ArticleDownloadResult {
|
|
58
60
|
title: string;
|
|
@@ -325,7 +325,7 @@ async function downloadImages(imgUrls, imgDir, headers, detectExt) {
|
|
|
325
325
|
* 6. File write
|
|
326
326
|
*/
|
|
327
327
|
export async function downloadArticle(data, options) {
|
|
328
|
-
const { output, downloadImages: shouldDownloadImages = true, imageHeaders, maxTitleLength = 80, configureTurndown, detectImageExt, frontmatterLabels, cleanSelectors, stdout = false, secureMarkdown = false, } = options;
|
|
328
|
+
const { output, downloadImages: shouldDownloadImages = true, imageHeaders, maxTitleLength = 80, configureTurndown, detectImageExt, frontmatterLabels, cleanSelectors, stdout = false, secureMarkdown = false, beforePublish, } = options;
|
|
329
329
|
const labels = { ...DEFAULT_LABELS, ...frontmatterLabels };
|
|
330
330
|
if (!data.title) {
|
|
331
331
|
return [{
|
|
@@ -350,12 +350,14 @@ export async function downloadArticle(data, options) {
|
|
|
350
350
|
// Convert HTML to Markdown
|
|
351
351
|
let markdown = convertToMarkdown(data.contentHtml, data.codeBlocks || [], configureTurndown, cleanSelectors, secureMarkdown);
|
|
352
352
|
const safeTitle = sanitizeFilename(data.title, maxTitleLength);
|
|
353
|
+
const stagingDir = !stdout && beforePublish
|
|
354
|
+
? path.join(output, `.bycli-article-${crypto.randomUUID()}.tmp`)
|
|
355
|
+
: undefined;
|
|
353
356
|
// Download images only when writing to disk. In stdout mode remote URLs
|
|
354
357
|
// stay intact so the piped output is self-contained.
|
|
355
358
|
if (!stdout && shouldDownloadImages && data.imageUrls && data.imageUrls.length > 0) {
|
|
356
359
|
const articleDir = path.join(output, safeTitle);
|
|
357
|
-
|
|
358
|
-
const imagesDir = path.join(articleDir, 'images');
|
|
360
|
+
const imagesDir = path.join(stagingDir ?? articleDir, 'images');
|
|
359
361
|
fs.mkdirSync(imagesDir, { recursive: true });
|
|
360
362
|
const urlMap = await downloadImages(data.imageUrls, imagesDir, imageHeaders, detectImageExt);
|
|
361
363
|
markdown = replaceImageUrls(markdown, urlMap);
|
|
@@ -396,7 +398,31 @@ export async function downloadArticle(data, options) {
|
|
|
396
398
|
fs.mkdirSync(articleDir, { recursive: true });
|
|
397
399
|
const filename = `${safeTitle}.md`;
|
|
398
400
|
const filePath = path.join(articleDir, filename);
|
|
399
|
-
|
|
401
|
+
if (stagingDir) {
|
|
402
|
+
fs.mkdirSync(stagingDir, { recursive: true });
|
|
403
|
+
const stagedMarkdown = path.join(stagingDir, filename);
|
|
404
|
+
fs.writeFileSync(stagedMarkdown, fullContent, { encoding: 'utf-8', flag: 'wx' });
|
|
405
|
+
try {
|
|
406
|
+
await beforePublish?.();
|
|
407
|
+
const stagedImages = path.join(stagingDir, 'images');
|
|
408
|
+
if (fs.existsSync(stagedImages)) {
|
|
409
|
+
const finalImages = path.join(articleDir, 'images');
|
|
410
|
+
fs.mkdirSync(finalImages, { recursive: true });
|
|
411
|
+
for (const image of fs.readdirSync(stagedImages)) {
|
|
412
|
+
fs.linkSync(path.join(stagedImages, image), path.join(finalImages, image));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// Same-filesystem rename makes the Markdown replacement atomic.
|
|
416
|
+
fs.renameSync(stagedMarkdown, filePath);
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
await beforePublish?.();
|
|
424
|
+
fs.writeFileSync(filePath, fullContent, 'utf-8');
|
|
425
|
+
}
|
|
400
426
|
return [{
|
|
401
427
|
title: data.title,
|
|
402
428
|
author: data.author || '-',
|
package/dist/src/errors.d.ts
CHANGED
|
@@ -67,6 +67,9 @@ export declare class TimeoutError extends CliError {
|
|
|
67
67
|
export declare class ArgumentError extends CliError {
|
|
68
68
|
constructor(message: string, hint?: string);
|
|
69
69
|
}
|
|
70
|
+
export declare class AdapterCoordinationError extends CliError {
|
|
71
|
+
constructor(code: string, message: string, temporary?: boolean, hint?: string);
|
|
72
|
+
}
|
|
70
73
|
export declare class EmptyResultError extends CliError {
|
|
71
74
|
constructor(command: string, hint?: string);
|
|
72
75
|
}
|
package/dist/src/errors.js
CHANGED
|
@@ -86,6 +86,11 @@ export class ArgumentError extends CliError {
|
|
|
86
86
|
super('ARGUMENT', message, hint, EXIT_CODES.USAGE_ERROR);
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
+
export class AdapterCoordinationError extends CliError {
|
|
90
|
+
constructor(code, message, temporary = false, hint) {
|
|
91
|
+
super(code, message, hint, temporary ? EXIT_CODES.TEMPFAIL : EXIT_CODES.USAGE_ERROR);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
89
94
|
export class EmptyResultError extends CliError {
|
|
90
95
|
constructor(command, hint) {
|
|
91
96
|
super('EMPTY_RESULT', `${command} returned no data`, hint ?? 'The page structure may have changed, or you may need to log in', EXIT_CODES.EMPTY_RESULT);
|
package/dist/src/execution.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export declare function executeCommand(cmd: CliCommand, rawKwargs: CommandArgs,
|
|
|
22
22
|
keepTab?: string;
|
|
23
23
|
windowMode?: string;
|
|
24
24
|
siteSession?: string;
|
|
25
|
+
adapterSession?: string;
|
|
26
|
+
adapterQueueTimeout?: string;
|
|
25
27
|
onTraceExport?: (trace: ObservationExportResult) => void;
|
|
26
28
|
}): Promise<unknown>;
|
|
27
29
|
export declare function prepareCommandArgs(cmd: CliCommand, rawKwargs: CommandArgs): CommandArgs;
|
package/dist/src/execution.js
CHANGED
|
@@ -16,10 +16,11 @@ import * as fs from 'node:fs';
|
|
|
16
16
|
import * as path from 'node:path';
|
|
17
17
|
import { getUserClisDir } from './config-paths.js';
|
|
18
18
|
import { executePipeline } from './pipeline/index.js';
|
|
19
|
-
import { adapterLoadError, ArgumentError, CliError, CommandExecutionError, attachTraceReceipt, getErrorMessage } from './errors.js';
|
|
19
|
+
import { AdapterCoordinationError, adapterLoadError, ArgumentError, CliError, CommandExecutionError, TimeoutError, attachTraceReceipt, getErrorMessage } from './errors.js';
|
|
20
20
|
import { shouldUseBrowserSession } from './capabilityRouting.js';
|
|
21
21
|
import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
|
|
22
22
|
import { resolveProfileContextId } from './browser/profile.js';
|
|
23
|
+
import { resolveAdapterLeaseContextId } from './browser/daemon-client.js';
|
|
23
24
|
import { emitHook } from './hooks.js';
|
|
24
25
|
import { log } from './logger.js';
|
|
25
26
|
import { isElectronApp } from './electron-apps.js';
|
|
@@ -27,6 +28,7 @@ import { probeCDP, resolveElectronEndpoint } from './launcher.js';
|
|
|
27
28
|
import { ObservationSession, exportObservationSession } from './observation/index.js';
|
|
28
29
|
import { resolveAdapterSourcePath } from './adapter-source.js';
|
|
29
30
|
import { canonicalizeManifestArgSchema, ManifestSchemaError } from './manifest-schema.js';
|
|
31
|
+
import { settleAdapterOperationAfterTimeout, withAdapterCommandLease } from './adapter-coordination.js';
|
|
30
32
|
import { capturedRegistryValues, closeRegistryTransaction, createRegistryTransaction, finalizeRegistryTransaction, resetRegistryTransactionStateForTests, rollbackRegistryTransaction, runRegistryTransaction, transactionGroupsForKey, } from './registry-transaction.js';
|
|
31
33
|
const _loadedModules = new Map();
|
|
32
34
|
/** Independent cache-busting generation; retained when an import promise is discarded. */
|
|
@@ -402,6 +404,8 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
|
|
|
402
404
|
let result;
|
|
403
405
|
try {
|
|
404
406
|
const resolvedBrowser = resolveBrowserRequirement(cmd, kwargs);
|
|
407
|
+
const adapterSession = normalizeAdapterSession(cmd, resolvedBrowser, opts.adapterSession);
|
|
408
|
+
const adapterQueueTimeoutSeconds = normalizeAdapterQueueTimeout(opts.adapterQueueTimeout, adapterSession);
|
|
405
409
|
const userTimeoutSec = readUserTimeoutSeconds(cmd, kwargs);
|
|
406
410
|
if (shouldUseBrowserSession(cmd, resolvedBrowser)) {
|
|
407
411
|
const electron = isElectronApp(cmd.site);
|
|
@@ -424,126 +428,154 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
|
|
|
424
428
|
const contextId = resolveProfileContextId(opts.profile);
|
|
425
429
|
const internal = cmd;
|
|
426
430
|
const siteSession = resolveSiteSession(cmd, opts.siteSession);
|
|
427
|
-
|
|
431
|
+
assertAdapterSessionLifecycle(siteSession, adapterSession);
|
|
432
|
+
const session = resolveAdapterBrowserSession(cmd, siteSession, adapterSession);
|
|
428
433
|
const keepTab = resolveKeepTab(siteSession, opts.keepTab);
|
|
429
434
|
const windowMode = resolveBrowserWindowMode('background', opts.windowMode);
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
if (preNavUrl && await shouldRunPreNav(cmd, page, siteSession, preNavUrl)) {
|
|
455
|
-
observation?.record({
|
|
456
|
-
stream: 'action',
|
|
457
|
-
name: 'pre_navigate',
|
|
458
|
-
phase: 'start',
|
|
459
|
-
data: { url: preNavUrl },
|
|
460
|
-
});
|
|
461
|
-
// Navigate directly — the extension's handleNavigate already has a fast-path
|
|
462
|
-
// that skips navigation if the tab is already at the target URL.
|
|
463
|
-
// This avoids an extra exec round-trip (getCurrentUrl) on first command and
|
|
464
|
-
// lets the extension create the automation window with the target URL directly
|
|
465
|
-
// instead of about:blank.
|
|
466
|
-
try {
|
|
467
|
-
await page.goto(preNavUrl);
|
|
468
|
-
observation?.record({
|
|
435
|
+
const executeInBrowser = () => browserSession(BrowserFactory, async (page) => {
|
|
436
|
+
// BrowserBridge.connect() has started/probed the daemon by this point. Resolve
|
|
437
|
+
// the daemon's canonical profile before acquiring a profile-scoped lease.
|
|
438
|
+
const leaseContextId = adapterSession
|
|
439
|
+
? await resolveAdapterLeaseContextId(contextId)
|
|
440
|
+
: undefined;
|
|
441
|
+
if (leaseContextId)
|
|
442
|
+
page.setContextId?.(leaseContextId);
|
|
443
|
+
const executeOnPage = async () => {
|
|
444
|
+
const observation = traceMode === 'off'
|
|
445
|
+
? null
|
|
446
|
+
: new ObservationSession({
|
|
447
|
+
scope: {
|
|
448
|
+
contextId,
|
|
449
|
+
session: adapterSession ? adapterSessionDiagnosticKey(cmd.site, adapterSession) : session,
|
|
450
|
+
target: page.getActivePage?.(),
|
|
451
|
+
site: cmd.site,
|
|
452
|
+
command: fullName(cmd),
|
|
453
|
+
adapterSourcePath: resolveAdapterSourcePath(internal)
|
|
454
|
+
?? resolveAdapterSourcePath(initialTraceCommand),
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
if (observation) {
|
|
458
|
+
observation.record({
|
|
469
459
|
stream: 'action',
|
|
470
|
-
name: '
|
|
471
|
-
phase: '
|
|
472
|
-
data: {
|
|
460
|
+
name: 'command',
|
|
461
|
+
phase: 'start',
|
|
462
|
+
data: { args: kwargs },
|
|
473
463
|
});
|
|
464
|
+
await page.startNetworkCapture?.().catch(() => false);
|
|
474
465
|
}
|
|
475
|
-
|
|
466
|
+
const preNavUrl = resolvePreNav(cmd);
|
|
467
|
+
if (preNavUrl && await shouldRunPreNav(cmd, page, siteSession, preNavUrl)) {
|
|
476
468
|
observation?.record({
|
|
477
469
|
stream: 'action',
|
|
478
470
|
name: 'pre_navigate',
|
|
479
|
-
phase: '
|
|
480
|
-
data: { url: preNavUrl
|
|
471
|
+
phase: 'start',
|
|
472
|
+
data: { url: preNavUrl },
|
|
481
473
|
});
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
474
|
+
// Navigate directly — the extension's handleNavigate already has a fast-path
|
|
475
|
+
// that skips navigation if the tab is already at the target URL.
|
|
476
|
+
// This avoids an extra exec round-trip (getCurrentUrl) on first command and
|
|
477
|
+
// lets the extension create the automation window with the target URL directly
|
|
478
|
+
// instead of about:blank.
|
|
479
|
+
try {
|
|
480
|
+
await page.goto(preNavUrl);
|
|
481
|
+
observation?.record({
|
|
482
|
+
stream: 'action',
|
|
483
|
+
name: 'pre_navigate',
|
|
484
|
+
phase: 'end',
|
|
485
|
+
data: { url: preNavUrl },
|
|
490
486
|
});
|
|
491
|
-
await collectObservationEvidence(observation, page).catch(() => { });
|
|
492
|
-
exportTraceArtifact(observation, 'failure', wrapped, opts.onTraceExport);
|
|
493
487
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
488
|
+
catch (err) {
|
|
489
|
+
observation?.record({
|
|
490
|
+
stream: 'action',
|
|
491
|
+
name: 'pre_navigate',
|
|
492
|
+
phase: 'error',
|
|
493
|
+
data: { url: preNavUrl, error: err instanceof Error ? err.message : String(err) },
|
|
494
|
+
});
|
|
495
|
+
const wrapped = new CommandExecutionError(`Pre-navigation to ${preNavUrl} failed: ${err instanceof Error ? err.message : err}`, 'Check that the site is reachable and the browser extension is running.');
|
|
496
|
+
if (observation && (traceMode === 'on' || traceMode === 'retain-on-failure')) {
|
|
497
|
+
observation.record({
|
|
498
|
+
stream: 'error',
|
|
499
|
+
message: wrapped.message,
|
|
500
|
+
stack: wrapped.stack,
|
|
501
|
+
code: wrapped.code,
|
|
502
|
+
hint: wrapped.hint,
|
|
503
|
+
});
|
|
504
|
+
await collectObservationEvidence(observation, page).catch(() => { });
|
|
505
|
+
exportTraceArtifact(observation, 'failure', wrapped, opts.onTraceExport);
|
|
506
|
+
}
|
|
507
|
+
throw wrapped;
|
|
508
|
+
}
|
|
513
509
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
510
|
+
try {
|
|
511
|
+
const browserTimeout = userTimeoutSec !== null
|
|
512
|
+
? userTimeoutSec + RUNTIME_TIMEOUT_PADDING_SECONDS
|
|
513
|
+
: DEFAULT_BROWSER_COMMAND_TIMEOUT;
|
|
514
|
+
const commandOperation = runCommand(cmd, page, kwargs, debug);
|
|
515
|
+
const result = adapterSession
|
|
516
|
+
? await settleAdapterOperationAfterTimeout(commandOperation, browserTimeout * 1_000, new TimeoutError(fullName(cmd), browserTimeout), async () => { await page.closeWindow?.().catch(() => { }); })
|
|
517
|
+
: await runWithTimeout(commandOperation, {
|
|
518
|
+
timeout: browserTimeout,
|
|
519
|
+
label: fullName(cmd),
|
|
520
|
+
});
|
|
521
|
+
observation?.record({
|
|
524
522
|
stream: 'action',
|
|
525
523
|
name: 'command',
|
|
526
|
-
phase: '
|
|
527
|
-
data: { error: err instanceof Error ? err.message : String(err) },
|
|
528
|
-
});
|
|
529
|
-
observation.record({
|
|
530
|
-
stream: 'error',
|
|
531
|
-
message: err instanceof Error ? err.message : String(err),
|
|
532
|
-
stack: err instanceof Error ? err.stack : undefined,
|
|
524
|
+
phase: 'end',
|
|
533
525
|
});
|
|
534
|
-
if (
|
|
526
|
+
if (observation && traceMode === 'on') {
|
|
535
527
|
await collectObservationEvidence(observation, page).catch(() => { });
|
|
536
|
-
exportTraceArtifact(observation, '
|
|
528
|
+
exportTraceArtifact(observation, 'success', undefined, opts.onTraceExport);
|
|
537
529
|
}
|
|
530
|
+
// Adapter commands are one-shot — release the current tab lease immediately
|
|
531
|
+
// instead of waiting for the 30s idle timeout. The automation container
|
|
532
|
+
// window stays open for reuse.
|
|
533
|
+
if (!keepTab)
|
|
534
|
+
await page.closeWindow?.().catch(() => { });
|
|
535
|
+
return result;
|
|
538
536
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
537
|
+
catch (err) {
|
|
538
|
+
if (observation) {
|
|
539
|
+
observation.record({
|
|
540
|
+
stream: 'action',
|
|
541
|
+
name: 'command',
|
|
542
|
+
phase: 'error',
|
|
543
|
+
data: { error: err instanceof Error ? err.message : String(err) },
|
|
544
|
+
});
|
|
545
|
+
observation.record({
|
|
546
|
+
stream: 'error',
|
|
547
|
+
message: err instanceof Error ? err.message : String(err),
|
|
548
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
549
|
+
});
|
|
550
|
+
if (traceMode === 'on' || traceMode === 'retain-on-failure') {
|
|
551
|
+
await collectObservationEvidence(observation, page).catch(() => { });
|
|
552
|
+
exportTraceArtifact(observation, 'failure', err, opts.onTraceExport);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
// Release the tab lease on failure too — without this, the lease lingers
|
|
556
|
+
// until the extension's idle timer fires (unreliable on Windows where
|
|
557
|
+
// MV3 service workers may be suspended before setTimeout triggers).
|
|
558
|
+
if (!keepTab)
|
|
559
|
+
await page.closeWindow?.().catch(() => { });
|
|
560
|
+
throw err;
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
if (!adapterSession)
|
|
564
|
+
return executeOnPage();
|
|
565
|
+
return withAdapterCommandLease({
|
|
566
|
+
requestId: crypto.randomUUID(),
|
|
567
|
+
contextId: leaseContextId,
|
|
568
|
+
surface: 'adapter',
|
|
569
|
+
site: cmd.site,
|
|
570
|
+
adapterSession,
|
|
571
|
+
sessionKey: session,
|
|
572
|
+
queueTimeoutMs: (adapterQueueTimeoutSeconds ?? 300) * 1_000,
|
|
573
|
+
maxParallel: cmd.adapterConcurrency?.maxParallel ?? 1,
|
|
574
|
+
}, executeOnPage, {
|
|
575
|
+
onLeaseLost: async () => { await page.closeWindow?.().catch(() => { }); },
|
|
576
|
+
});
|
|
546
577
|
}, { session, cdpEndpoint, contextId, windowMode, surface: 'adapter', siteSession });
|
|
578
|
+
result = await executeInBrowser();
|
|
547
579
|
}
|
|
548
580
|
else {
|
|
549
581
|
// Non-browser commands: enforce a timeout only when the command exposes
|
|
@@ -674,11 +706,46 @@ function normalizeSiteSession(raw) {
|
|
|
674
706
|
function resolveSiteSession(cmd, rawOption) {
|
|
675
707
|
return normalizeSiteSession(rawOption) ?? cmd.siteSession ?? 'ephemeral';
|
|
676
708
|
}
|
|
677
|
-
function
|
|
678
|
-
if (
|
|
679
|
-
return
|
|
709
|
+
function normalizeAdapterSession(cmd, resolvedBrowser, rawOption) {
|
|
710
|
+
if (rawOption === undefined || rawOption === null || rawOption === '')
|
|
711
|
+
return undefined;
|
|
712
|
+
if (!resolvedBrowser || cmd.adapterConcurrency?.isolatedTabs !== true) {
|
|
713
|
+
throw new AdapterCoordinationError('ADAPTER_SESSION_NOT_SUPPORTED', `${fullName(cmd)} does not support named Adapter sessions`);
|
|
714
|
+
}
|
|
715
|
+
const value = String(rawOption);
|
|
716
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(value)) {
|
|
717
|
+
throw new AdapterCoordinationError('INVALID_ADAPTER_SESSION', '--adapter-session must match [A-Za-z0-9][A-Za-z0-9_-]{0,63}');
|
|
718
|
+
}
|
|
719
|
+
return value;
|
|
720
|
+
}
|
|
721
|
+
function normalizeAdapterQueueTimeout(rawOption, adapterSession) {
|
|
722
|
+
if (rawOption === undefined || rawOption === null || rawOption === '') {
|
|
723
|
+
return adapterSession ? 300 : undefined;
|
|
724
|
+
}
|
|
725
|
+
if (!adapterSession) {
|
|
726
|
+
throw new AdapterCoordinationError('INVALID_ADAPTER_QUEUE_TIMEOUT', '--adapter-queue-timeout requires --adapter-session');
|
|
727
|
+
}
|
|
728
|
+
const value = typeof rawOption === 'string' && /^\d+$/.test(rawOption) ? Number(rawOption) : NaN;
|
|
729
|
+
if (!Number.isInteger(value) || value < 1 || value > 3600) {
|
|
730
|
+
throw new AdapterCoordinationError('INVALID_ADAPTER_QUEUE_TIMEOUT', '--adapter-queue-timeout must be an integer between 1 and 3600 seconds');
|
|
731
|
+
}
|
|
732
|
+
return value;
|
|
733
|
+
}
|
|
734
|
+
function assertAdapterSessionLifecycle(siteSession, adapterSession) {
|
|
735
|
+
if (adapterSession && siteSession !== 'persistent') {
|
|
736
|
+
throw new AdapterCoordinationError('ADAPTER_SESSION_REQUIRES_PERSISTENT', '--adapter-session requires --site-session persistent');
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
function resolveAdapterBrowserSession(cmd, siteSession, adapterSession) {
|
|
740
|
+
if (siteSession === 'persistent') {
|
|
741
|
+
return adapterSession ? `site:${cmd.site}:${adapterSession}` : `site:${cmd.site}`;
|
|
742
|
+
}
|
|
680
743
|
return `site:${cmd.site}:${crypto.randomUUID()}`;
|
|
681
744
|
}
|
|
745
|
+
function adapterSessionDiagnosticKey(site, adapterSession) {
|
|
746
|
+
const digest = crypto.createHash('sha256').update(adapterSession).digest('hex').slice(0, 12);
|
|
747
|
+
return `site:${site}:adapter-${digest}`;
|
|
748
|
+
}
|
|
682
749
|
function normalizeBooleanOption(name, raw) {
|
|
683
750
|
if (raw === undefined || raw === '')
|
|
684
751
|
return null;
|
package/dist/src/help.d.ts
CHANGED
|
@@ -82,6 +82,7 @@ export declare function siteHelpData(site: string, commands: readonly CliCommand
|
|
|
82
82
|
export declare function commandHelpData(cmd: CliCommand): Record<string, unknown>;
|
|
83
83
|
export declare function formatCommonOptionsHelpText(): string;
|
|
84
84
|
export declare function formatBrowserCommonOptionsHelpText(): string;
|
|
85
|
+
export declare function formatAdapterSessionOptionsHelpText(): string;
|
|
85
86
|
export declare function formatSiteHelpText(site: string, commands: readonly CliCommand[]): string;
|
|
86
87
|
export declare function formatCommandHelpText(cmd: CliCommand): string;
|
|
87
88
|
export declare function installStructuredHelp(command: Command, data: () => unknown, textSuffix?: string | (() => string)): void;
|