imprint-mcp 0.4.1 → 0.4.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/README.md +2 -0
- package/package.json +3 -1
- package/src/cli.ts +74 -3
- package/src/imprint/backend-ladder.ts +41 -7
- package/src/imprint/cron.ts +14 -1
- package/src/imprint/doctor.ts +19 -1
- package/src/imprint/mcp-maintenance.ts +71 -6
- package/src/imprint/mcp-server.ts +29 -4
- package/src/imprint/playbook-runner.ts +82 -10
- package/src/imprint/probe-backends.ts +369 -63
- package/src/imprint/types.ts +12 -0
- package/src/imprint/update.ts +73 -0
package/README.md
CHANGED
|
@@ -180,6 +180,8 @@ When an API call gets blocked, Imprint doesn't jump to DOM replay. It escalates
|
|
|
180
180
|
|
|
181
181
|
The full order is `fetch → fetch-bootstrap → cdp-replay → stealth-fetch → playbook`; `auto` mode walks it and stops at the first backend that works.
|
|
182
182
|
|
|
183
|
+
For bot-protected sites, `imprint probe-backends <site> --tool <toolName>` writes a `backends.json` preference cache so cron and MCP start from the known-good backend instead of rediscovering blocked rungs. Use `imprint probe-backends <site> --all` to refresh every tool in a multi-tool site; `imprint mcp status` reports stale or invalid backend caches before they quietly fall back to the default ladder. CDP replay records both cold and warm timings when it succeeds: a timeout-safe cold start may rank by its fast warm runtime, but a cold start above the preferred threshold stays behind cold-safe backends in durable cache order.
|
|
184
|
+
|
|
183
185
|
Every recording compiles to *both* `workflow.json` and `playbook.yaml`, so the ladder always has a DOM fallback.
|
|
184
186
|
|
|
185
187
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imprint-mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Teach an AI agent how to use any website. Once. Records a real browser session + narration; generates a deterministic MCP tool plus a DOM-replay playbook fallback.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"playwright-extra": "^4.3.6",
|
|
64
64
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
|
65
65
|
"redactum": "^1.1.0",
|
|
66
|
+
"semver": "^7.8.4",
|
|
66
67
|
"yaml": "^2.8.4",
|
|
67
68
|
"zod": "^3.24.0"
|
|
68
69
|
},
|
|
@@ -72,6 +73,7 @@
|
|
|
72
73
|
"@types/chrome-remote-interface": "^0.31.14",
|
|
73
74
|
"@types/node": "^22.10.0",
|
|
74
75
|
"@types/node-cron": "^3.0.11",
|
|
76
|
+
"@types/semver": "^7.7.1",
|
|
75
77
|
"knip": "^5.0.0",
|
|
76
78
|
"madge": "^8.0.0",
|
|
77
79
|
"typescript": "^5.7.0"
|
package/src/cli.ts
CHANGED
|
@@ -346,9 +346,13 @@ export const VERB_HELP: Record<string, VerbHelp> = {
|
|
|
346
346
|
},
|
|
347
347
|
'probe-backends': {
|
|
348
348
|
summary: 'Try each backend once and cache the working order to backends.json.',
|
|
349
|
-
usage: [
|
|
349
|
+
usage: [
|
|
350
|
+
'imprint probe-backends <site> [--tool <toolName>] [--out <path>] [--param k=v]…',
|
|
351
|
+
'imprint probe-backends <site> --all [--param k=v]…',
|
|
352
|
+
],
|
|
350
353
|
flags: [
|
|
351
354
|
{ name: '--tool <toolName>', description: 'Select a generated tool for multi-tool sites.' },
|
|
355
|
+
{ name: '--all', description: 'Probe every generated tool for the site.' },
|
|
352
356
|
{ name: '--out <path>', description: 'Override backends.json output path.' },
|
|
353
357
|
{ name: '--param k=v', description: 'Override a workflow parameter (repeatable).' },
|
|
354
358
|
],
|
|
@@ -451,6 +455,17 @@ export const VERB_HELP: Record<string, VerbHelp> = {
|
|
|
451
455
|
],
|
|
452
456
|
example: 'imprint mcp status',
|
|
453
457
|
},
|
|
458
|
+
update: {
|
|
459
|
+
summary: 'Check for updates and install the latest version of imprint.',
|
|
460
|
+
usage: ['imprint update [--check]'],
|
|
461
|
+
flags: [
|
|
462
|
+
{
|
|
463
|
+
name: '--check',
|
|
464
|
+
description: 'Only check whether an update is available; do not install.',
|
|
465
|
+
},
|
|
466
|
+
],
|
|
467
|
+
example: 'imprint update',
|
|
468
|
+
},
|
|
454
469
|
};
|
|
455
470
|
|
|
456
471
|
function printVerbHelp(verb: string): void {
|
|
@@ -621,7 +636,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
621
636
|
|
|
622
637
|
case 'doctor': {
|
|
623
638
|
const { doctor, reportDoctor } = await import('./imprint/doctor.ts');
|
|
624
|
-
const report = reportDoctor(doctor());
|
|
639
|
+
const report = reportDoctor(await doctor());
|
|
625
640
|
for (const line of report.lines) console.log(line);
|
|
626
641
|
return report.ok ? 0 : 1;
|
|
627
642
|
}
|
|
@@ -1201,6 +1216,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
1201
1216
|
const { values } = parseArgs({
|
|
1202
1217
|
args: argv.slice(2),
|
|
1203
1218
|
options: {
|
|
1219
|
+
all: { type: 'boolean' },
|
|
1204
1220
|
out: { type: 'string' },
|
|
1205
1221
|
tool: { type: 'string' },
|
|
1206
1222
|
param: { type: 'string', multiple: true },
|
|
@@ -1209,7 +1225,30 @@ async function main(argv: string[]): Promise<number> {
|
|
|
1209
1225
|
});
|
|
1210
1226
|
const overrides = tryParseParamKV(values.param);
|
|
1211
1227
|
if (overrides === null) return 2;
|
|
1212
|
-
|
|
1228
|
+
if (values.all && values.tool) {
|
|
1229
|
+
console.error('error: --all cannot be combined with --tool');
|
|
1230
|
+
return 2;
|
|
1231
|
+
}
|
|
1232
|
+
if (values.all && values.out) {
|
|
1233
|
+
console.error('error: --all cannot be combined with --out');
|
|
1234
|
+
return 2;
|
|
1235
|
+
}
|
|
1236
|
+
const { probeAllBackends, probeBackends } = await import('./imprint/probe-backends.ts');
|
|
1237
|
+
if (values.all) {
|
|
1238
|
+
const results = await probeAllBackends({
|
|
1239
|
+
site,
|
|
1240
|
+
paramOverrides: Object.keys(overrides).length > 0 ? overrides : undefined,
|
|
1241
|
+
});
|
|
1242
|
+
for (const result of results) {
|
|
1243
|
+
console.log(`[imprint] probed → ${result.outPath}`);
|
|
1244
|
+
console.log(`[imprint] preferred order: ${result.cache.preferredOrder.join(' → ')}`);
|
|
1245
|
+
}
|
|
1246
|
+
console.log('');
|
|
1247
|
+
console.log(
|
|
1248
|
+
'[imprint] cron + mcp-server now skip futile rungs at startup using these caches.',
|
|
1249
|
+
);
|
|
1250
|
+
return 0;
|
|
1251
|
+
}
|
|
1213
1252
|
const result = await probeBackends({
|
|
1214
1253
|
site,
|
|
1215
1254
|
outPath: values.out,
|
|
@@ -1482,6 +1521,38 @@ async function main(argv: string[]): Promise<number> {
|
|
|
1482
1521
|
return 0;
|
|
1483
1522
|
}
|
|
1484
1523
|
|
|
1524
|
+
case 'update': {
|
|
1525
|
+
const { checkForUpdate, performUpdate } = await import('./imprint/update.ts');
|
|
1526
|
+
const checkOnly = argv.slice(1).includes('--check');
|
|
1527
|
+
if (checkOnly) {
|
|
1528
|
+
const result = await checkForUpdate();
|
|
1529
|
+
if (!result) {
|
|
1530
|
+
console.error('Could not reach npm registry.');
|
|
1531
|
+
return 1;
|
|
1532
|
+
}
|
|
1533
|
+
console.log(`Current: v${result.current}`);
|
|
1534
|
+
console.log(`Latest: v${result.latest}`);
|
|
1535
|
+
if (result.updateAvailable) {
|
|
1536
|
+
console.log('\nUpdate available — run `imprint update` to install.');
|
|
1537
|
+
} else {
|
|
1538
|
+
console.log('\nAlready up to date.');
|
|
1539
|
+
}
|
|
1540
|
+
return 0;
|
|
1541
|
+
}
|
|
1542
|
+
console.log('Checking for updates...');
|
|
1543
|
+
const result = await performUpdate();
|
|
1544
|
+
if (result.from === result.to && result.ok) {
|
|
1545
|
+
console.log(`imprint v${result.from} is already the latest version.`);
|
|
1546
|
+
return 0;
|
|
1547
|
+
}
|
|
1548
|
+
if (result.ok) {
|
|
1549
|
+
console.log(`Updated imprint: v${result.from} → v${result.to}`);
|
|
1550
|
+
return 0;
|
|
1551
|
+
}
|
|
1552
|
+
console.error(`Update failed: ${result.error}`);
|
|
1553
|
+
return 1;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1485
1556
|
default: {
|
|
1486
1557
|
const suggestion = closestVerb(verb);
|
|
1487
1558
|
const tail = suggestion ? `did you mean \`imprint ${suggestion}\`?` : 'run `imprint --help`';
|
|
@@ -75,6 +75,8 @@ interface LadderResult {
|
|
|
75
75
|
const log = createLog('backend');
|
|
76
76
|
|
|
77
77
|
const DEFAULT_LADDER: ConcreteBackend[] = ['fetch', 'stealth-fetch', 'playbook'];
|
|
78
|
+
const DEFAULT_PLAYBOOK_BACKEND_TIMEOUT_MS = 75_000;
|
|
79
|
+
const DEFAULT_PLAYBOOK_BACKEND_STEP_TIMEOUT_MS = 20_000;
|
|
78
80
|
|
|
79
81
|
/** Process-scoped memo of the backend that last succeeded for a site on the
|
|
80
82
|
* compile/test path (`runWorkflowWithLadder`). Lets the param-coverage suite
|
|
@@ -182,6 +184,22 @@ function sleepMs(ms: number): Promise<void> {
|
|
|
182
184
|
return new Promise((r) => setTimeout(r, ms));
|
|
183
185
|
}
|
|
184
186
|
|
|
187
|
+
function playbookBackendTimeoutMs(): number {
|
|
188
|
+
return positiveEnvMs('IMPRINT_PLAYBOOK_BACKEND_TIMEOUT_MS', DEFAULT_PLAYBOOK_BACKEND_TIMEOUT_MS);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function playbookBackendStepTimeoutMs(): number {
|
|
192
|
+
return positiveEnvMs(
|
|
193
|
+
'IMPRINT_PLAYBOOK_BACKEND_STEP_TIMEOUT_MS',
|
|
194
|
+
DEFAULT_PLAYBOOK_BACKEND_STEP_TIMEOUT_MS,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function positiveEnvMs(name: string, fallback: number): number {
|
|
199
|
+
const raw = Number(process.env[name] ?? fallback);
|
|
200
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : fallback;
|
|
201
|
+
}
|
|
202
|
+
|
|
185
203
|
function withWorkflowDefaults(
|
|
186
204
|
workflow: Workflow,
|
|
187
205
|
params: Record<string, string | number | boolean>,
|
|
@@ -317,7 +335,8 @@ export async function runWithLadder(
|
|
|
317
335
|
result = await runCdpReplay(tool, params, options?.cdpPool);
|
|
318
336
|
break;
|
|
319
337
|
case 'stealth-fetch': {
|
|
320
|
-
const
|
|
338
|
+
const paramsWithDefaults = withWorkflowDefaults(tool.workflow, params);
|
|
339
|
+
const sf = await ensureStealthFetch(tool, stealthCache, paramsWithDefaults);
|
|
321
340
|
// When the workflow declares a bootstrap block, mint its declared
|
|
322
341
|
// session-token state (CSRF cookies etc.) from the SAME stealth
|
|
323
342
|
// session that provides the transport cookies. Without this, a
|
|
@@ -327,7 +346,7 @@ export async function runWithLadder(
|
|
|
327
346
|
const initialState = tool.workflow.bootstrap
|
|
328
347
|
? await stealthBootstrapState(sf, tool.workflow.bootstrap)
|
|
329
348
|
: undefined;
|
|
330
|
-
result = await tool.toolFn(
|
|
349
|
+
result = await tool.toolFn(paramsWithDefaults, { fetchImpl: sf.fetchImpl, initialState });
|
|
331
350
|
break;
|
|
332
351
|
}
|
|
333
352
|
case 'playbook': {
|
|
@@ -339,6 +358,8 @@ export async function runWithLadder(
|
|
|
339
358
|
playbook: playbookPath(assetRoot, tool.site, tool.dir),
|
|
340
359
|
params: paramsWithDefaults,
|
|
341
360
|
site: tool.site,
|
|
361
|
+
stepTimeoutMs: playbookBackendStepTimeoutMs(),
|
|
362
|
+
maxDurationMs: playbookBackendTimeoutMs(),
|
|
342
363
|
});
|
|
343
364
|
break;
|
|
344
365
|
}
|
|
@@ -1213,8 +1234,21 @@ async function stealthBootstrapState(
|
|
|
1213
1234
|
return state;
|
|
1214
1235
|
}
|
|
1215
1236
|
|
|
1216
|
-
function ensureStealthFetch(
|
|
1217
|
-
|
|
1237
|
+
async function ensureStealthFetch(
|
|
1238
|
+
tool: ResolvedTool,
|
|
1239
|
+
cache: Map<string, StealthFetch>,
|
|
1240
|
+
params: Record<string, string | number | boolean>,
|
|
1241
|
+
): Promise<StealthFetch> {
|
|
1242
|
+
const credentials = (await loadCredentialStore(tool.site)) ?? {
|
|
1243
|
+
site: tool.site,
|
|
1244
|
+
cookies: [],
|
|
1245
|
+
values: {},
|
|
1246
|
+
};
|
|
1247
|
+
const bootstrapUrl = tool.workflow.bootstrap?.url
|
|
1248
|
+
? substituteString(tool.workflow.bootstrap.url, params, credentials, [], 'url')
|
|
1249
|
+
: undefined;
|
|
1250
|
+
const cacheKey = bootstrapUrl ? `${tool.site}:${bootstrapUrl}` : tool.site;
|
|
1251
|
+
const cached = cache.get(cacheKey);
|
|
1218
1252
|
if (cached) return cached;
|
|
1219
1253
|
const sf = createStealthFetch({
|
|
1220
1254
|
baseUrl: pickBaseUrl(tool),
|
|
@@ -1223,9 +1257,9 @@ function ensureStealthFetch(tool: ResolvedTool, cache: Map<string, StealthFetch>
|
|
|
1223
1257
|
// minted in the same session as the anti-bot cookies. Otherwise the
|
|
1224
1258
|
// stealth rung can't satisfy a `${state.X}` the workflow bootstrap was
|
|
1225
1259
|
// supposed to provide, and escalation from fetch-bootstrap dead-ends.
|
|
1226
|
-
bootstrapUrl
|
|
1260
|
+
bootstrapUrl,
|
|
1227
1261
|
});
|
|
1228
|
-
cache.set(
|
|
1262
|
+
cache.set(cacheKey, sf);
|
|
1229
1263
|
return sf;
|
|
1230
1264
|
}
|
|
1231
1265
|
|
|
@@ -1452,7 +1486,7 @@ export async function runWorkflowWithLadder(opts: {
|
|
|
1452
1486
|
// A backend that finishes AFTER the probe returned (it lost the race but
|
|
1453
1487
|
// is still cold-starting Chrome) pools its browser late — arm the idle
|
|
1454
1488
|
// close so it's torn down rather than left lingering.
|
|
1455
|
-
void inner.finally(() => armCompileCdpIdleClose());
|
|
1489
|
+
void inner.finally(() => armCompileCdpIdleClose()).catch(() => {});
|
|
1456
1490
|
const r = await Promise.race([
|
|
1457
1491
|
inner,
|
|
1458
1492
|
sleepMs(PROBE_TIMEOUT_MS).then(
|
package/src/imprint/cron.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { loadJsonFile } from './load-json.ts';
|
|
|
16
16
|
import { createLog, isDebug } from './log.ts';
|
|
17
17
|
import { evaluateNotifyWhen, notify } from './notify.ts';
|
|
18
18
|
import { imprintHomeDir } from './paths.ts';
|
|
19
|
-
import { loadBackendsCache } from './probe-backends.ts';
|
|
19
|
+
import { loadBackendsCache, persistRuntimeBackendsCache } from './probe-backends.ts';
|
|
20
20
|
import { checkSiteCredentialsReady } from './runtime.ts';
|
|
21
21
|
import { availableSitesHint } from './sites.ts';
|
|
22
22
|
import type { StealthFetch } from './stealth-fetch.ts';
|
|
@@ -100,6 +100,19 @@ async function runOnce(
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
if (result.ok) {
|
|
103
|
+
try {
|
|
104
|
+
const cache = persistRuntimeBackendsCache({
|
|
105
|
+
tool,
|
|
106
|
+
assetRoot,
|
|
107
|
+
usedBackend,
|
|
108
|
+
attempts,
|
|
109
|
+
});
|
|
110
|
+
if (cache) log(` learned backend order: ${cache.preferredOrder.join(' → ')}`);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
log(
|
|
113
|
+
` warning: could not persist backend order: ${err instanceof Error ? err.message : String(err)}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
103
116
|
const data = typeof result.data === 'string' ? result.data : JSON.stringify(result.data);
|
|
104
117
|
// Cap the inline preview at ~500 chars; full payload available via
|
|
105
118
|
// IMPRINT_DEBUG=1. Long-running daemons flood stderr otherwise.
|
package/src/imprint/doctor.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { join as pathJoin } from 'node:path';
|
|
|
8
8
|
import { findChromium } from './chromium.ts';
|
|
9
9
|
import { defaultHermesConfigPath } from './install.ts';
|
|
10
10
|
import { getProviderStatuses } from './llm.ts';
|
|
11
|
+
import { checkForUpdate } from './update.ts';
|
|
11
12
|
import { VERSION } from './version.ts';
|
|
12
13
|
|
|
13
14
|
export interface CheckResult {
|
|
@@ -17,9 +18,10 @@ export interface CheckResult {
|
|
|
17
18
|
fix?: string;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
export function doctor(): CheckResult[] {
|
|
21
|
+
export async function doctor(): Promise<CheckResult[]> {
|
|
21
22
|
return [
|
|
22
23
|
checkBun(),
|
|
24
|
+
await checkLatestVersion(),
|
|
23
25
|
checkChromium(),
|
|
24
26
|
checkPlaywrightChromium(),
|
|
25
27
|
checkVirtualDisplay(),
|
|
@@ -31,6 +33,22 @@ export function doctor(): CheckResult[] {
|
|
|
31
33
|
];
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
async function checkLatestVersion(): Promise<CheckResult> {
|
|
37
|
+
const result = await checkForUpdate();
|
|
38
|
+
if (!result) {
|
|
39
|
+
return { name: 'Latest version', ok: true, detail: `v${VERSION} (could not reach registry)` };
|
|
40
|
+
}
|
|
41
|
+
if (!result.updateAvailable) {
|
|
42
|
+
return { name: 'Latest version', ok: true, detail: `v${VERSION} (up to date)` };
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
name: 'Latest version',
|
|
46
|
+
ok: true,
|
|
47
|
+
detail: `v${result.current} → v${result.latest} available`,
|
|
48
|
+
fix: 'run: imprint update',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
34
52
|
function checkBun(): CheckResult {
|
|
35
53
|
const v = process.versions.bun;
|
|
36
54
|
if (!v) {
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import * as p from '@clack/prompts';
|
|
28
28
|
import YAML from 'yaml';
|
|
29
29
|
import { imprintHomeDir, localSiteDir } from './paths.ts';
|
|
30
|
+
import { type BackendsCacheStatus, loadBackendsCacheStatus } from './probe-backends.ts';
|
|
30
31
|
import {
|
|
31
32
|
type WorkflowState,
|
|
32
33
|
loadTeachState,
|
|
@@ -38,7 +39,7 @@ import {
|
|
|
38
39
|
|
|
39
40
|
type McpClient = 'claude-code' | 'codex' | 'claude-desktop' | 'openclaw' | 'hermes';
|
|
40
41
|
type LocalDeleteMode = 'none' | 'tool' | 'site';
|
|
41
|
-
type IssueKind = 'missing-session' | 'stale-registration';
|
|
42
|
+
type IssueKind = 'missing-session' | 'stale-registration' | 'stale-backends' | 'invalid-backends';
|
|
42
43
|
|
|
43
44
|
const CLIENTS: McpClient[] = ['claude-code', 'codex', 'claude-desktop', 'openclaw', 'hermes'];
|
|
44
45
|
const DISABLED_STORE_VERSION = 1;
|
|
@@ -81,6 +82,15 @@ interface LocalToolStatus {
|
|
|
81
82
|
hasPlaybook: boolean;
|
|
82
83
|
hasBackends: boolean;
|
|
83
84
|
hasCron: boolean;
|
|
85
|
+
backendCache: PublicBackendsCacheStatus;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface PublicBackendsCacheStatus {
|
|
89
|
+
status: BackendsCacheStatus['status'];
|
|
90
|
+
path: string | null;
|
|
91
|
+
preferredOrder?: string[];
|
|
92
|
+
reason?: string;
|
|
93
|
+
remediation?: string;
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
interface LocalWorkflowStatus {
|
|
@@ -475,7 +485,13 @@ function cmdStatus(argv: string[]): number {
|
|
|
475
485
|
const status = scanMcpStatus({ site });
|
|
476
486
|
if (flags.json === true) console.log(JSON.stringify(status, null, 2));
|
|
477
487
|
else console.log(formatMcpStatus(status));
|
|
478
|
-
return status.issues.some(
|
|
488
|
+
return status.issues.some(
|
|
489
|
+
(i) =>
|
|
490
|
+
i.kind === 'stale-registration' ||
|
|
491
|
+
i.kind === 'missing-session' ||
|
|
492
|
+
i.kind === 'stale-backends' ||
|
|
493
|
+
i.kind === 'invalid-backends',
|
|
494
|
+
)
|
|
479
495
|
? 1
|
|
480
496
|
: 0;
|
|
481
497
|
}
|
|
@@ -691,6 +707,11 @@ function issueFixHint(issue: McpIssue): string | null {
|
|
|
691
707
|
return `choose "Fix an issue" or run: imprint mcp delete ${issue.name ?? `imprint-${issue.site}`} --client ${issue.client ?? 'all'} --yes`;
|
|
692
708
|
case 'missing-session':
|
|
693
709
|
return `choose "Fix an issue" or run: imprint mcp prune-state --site ${issue.site} --missing-session --yes`;
|
|
710
|
+
case 'stale-backends':
|
|
711
|
+
case 'invalid-backends':
|
|
712
|
+
return issue.path
|
|
713
|
+
? `run: imprint probe-backends ${issue.site}${issue.workflow ? ` --tool ${issue.workflow}` : ''}`
|
|
714
|
+
: `run: imprint probe-backends ${issue.site}${issue.workflow ? ` --tool ${issue.workflow}` : ''}`;
|
|
694
715
|
}
|
|
695
716
|
return null;
|
|
696
717
|
}
|
|
@@ -712,26 +733,32 @@ function scanLocalSites(ctx: MaintenanceContext): LocalSiteStatus[] {
|
|
|
712
733
|
if (entry === 'node_modules' || entry.startsWith('.')) continue;
|
|
713
734
|
const dir = pathJoin(ctx.imprintHome, entry);
|
|
714
735
|
if (!safeIsDir(dir)) continue;
|
|
715
|
-
sites.push(scanLocalSite(entry, dir));
|
|
736
|
+
sites.push(scanLocalSite(entry, dir, ctx.imprintHome));
|
|
716
737
|
}
|
|
717
738
|
return sites;
|
|
718
739
|
}
|
|
719
740
|
|
|
720
|
-
function scanLocalSite(site: string, dir: string): LocalSiteStatus {
|
|
741
|
+
function scanLocalSite(site: string, dir: string, imprintHome: string): LocalSiteStatus {
|
|
721
742
|
const tools: LocalToolStatus[] = [];
|
|
722
743
|
for (const entry of readdirSync(dir).sort()) {
|
|
723
744
|
if (entry === 'sessions' || entry === '_shared' || entry.startsWith('.')) continue;
|
|
724
745
|
const toolDir = pathJoin(dir, entry);
|
|
725
746
|
if (!safeIsDir(toolDir)) continue;
|
|
747
|
+
const toolName = workflowJsonToolName(toolDir) ?? entry;
|
|
748
|
+
const cacheStatus = loadBackendsCacheStatus(site, imprintHome, toolDir, {
|
|
749
|
+
warn: false,
|
|
750
|
+
toolName,
|
|
751
|
+
});
|
|
726
752
|
tools.push({
|
|
727
753
|
site,
|
|
728
|
-
toolName
|
|
754
|
+
toolName,
|
|
729
755
|
dir: toolDir,
|
|
730
756
|
complete: existsSync(pathJoin(toolDir, 'index.ts')),
|
|
731
757
|
hasWorkflow: existsSync(pathJoin(toolDir, 'workflow.json')),
|
|
732
758
|
hasPlaybook: existsSync(pathJoin(toolDir, 'playbook.yaml')),
|
|
733
759
|
hasBackends: existsSync(pathJoin(toolDir, 'backends.json')),
|
|
734
760
|
hasCron: existsSync(pathJoin(toolDir, 'cron.json')),
|
|
761
|
+
backendCache: publicBackendsCacheStatus(cacheStatus),
|
|
735
762
|
});
|
|
736
763
|
}
|
|
737
764
|
|
|
@@ -746,6 +773,29 @@ function scanLocalSite(site: string, dir: string): LocalSiteStatus {
|
|
|
746
773
|
return { site, dir, tools, workflows };
|
|
747
774
|
}
|
|
748
775
|
|
|
776
|
+
function publicBackendsCacheStatus(status: BackendsCacheStatus): PublicBackendsCacheStatus {
|
|
777
|
+
if (status.status === 'ok') {
|
|
778
|
+
return {
|
|
779
|
+
status: status.status,
|
|
780
|
+
path: status.path,
|
|
781
|
+
preferredOrder: status.cache.preferredOrder,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
if (status.status === 'missing') {
|
|
785
|
+
return {
|
|
786
|
+
status: status.status,
|
|
787
|
+
path: status.path,
|
|
788
|
+
remediation: status.remediation,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
return {
|
|
792
|
+
status: status.status,
|
|
793
|
+
path: status.path,
|
|
794
|
+
reason: status.reason,
|
|
795
|
+
remediation: status.remediation,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
749
799
|
function workflowStatus(
|
|
750
800
|
site: string,
|
|
751
801
|
name: string,
|
|
@@ -795,6 +845,21 @@ function collectIssues(opts: {
|
|
|
795
845
|
const sitesByName = new Map(opts.sites.map((s) => [s.site, s]));
|
|
796
846
|
|
|
797
847
|
for (const site of opts.sites) {
|
|
848
|
+
for (const tool of site.tools) {
|
|
849
|
+
if (tool.backendCache.status === 'stale' || tool.backendCache.status === 'invalid') {
|
|
850
|
+
issues.push({
|
|
851
|
+
kind: tool.backendCache.status === 'stale' ? 'stale-backends' : 'invalid-backends',
|
|
852
|
+
site: site.site,
|
|
853
|
+
workflow: tool.toolName,
|
|
854
|
+
path: tool.backendCache.path ?? undefined,
|
|
855
|
+
message:
|
|
856
|
+
tool.backendCache.status === 'stale'
|
|
857
|
+
? `${site.site}/${tool.toolName} has a stale backends.json; runtime will fall back to the default ladder until reprobed`
|
|
858
|
+
: `${site.site}/${tool.toolName} has an invalid backends.json; runtime will fall back to the default ladder until reprobed`,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
798
863
|
for (const wf of site.workflows) {
|
|
799
864
|
if (wf.missingSession) {
|
|
800
865
|
issues.push({
|
|
@@ -1091,7 +1156,7 @@ function pruneTeachState(
|
|
|
1091
1156
|
for (const site of sites) {
|
|
1092
1157
|
const statePath = teachStatePath(site);
|
|
1093
1158
|
if (!existsSync(statePath)) continue;
|
|
1094
|
-
const status = scanLocalSite(site, localSiteDir(site));
|
|
1159
|
+
const status = scanLocalSite(site, localSiteDir(site), ctx.imprintHome);
|
|
1095
1160
|
const remove = new Set(
|
|
1096
1161
|
status.workflows
|
|
1097
1162
|
.filter(
|
|
@@ -20,7 +20,7 @@ import { resolveLadder, runWithLadder } from './backend-ladder.ts';
|
|
|
20
20
|
import type { CdpBrowserFetch } from './cdp-browser-fetch.ts';
|
|
21
21
|
import { createLog } from './log.ts';
|
|
22
22
|
import { imprintHomeDir } from './paths.ts';
|
|
23
|
-
import {
|
|
23
|
+
import { loadBackendsCacheStatus, persistRuntimeBackendsCache } from './probe-backends.ts';
|
|
24
24
|
import { checkSiteCredentialsReady } from './runtime.ts';
|
|
25
25
|
import { availableSitesHint } from './sites.ts';
|
|
26
26
|
import type { StealthFetch } from './stealth-fetch.ts';
|
|
@@ -173,7 +173,7 @@ function buildServer(
|
|
|
173
173
|
|
|
174
174
|
try {
|
|
175
175
|
const ladder = resolveLadder('auto', tool.preferredOrder);
|
|
176
|
-
const { result, usedBackend } = await runWithLadder(
|
|
176
|
+
const { result, usedBackend, attempts } = await runWithLadder(
|
|
177
177
|
ladder,
|
|
178
178
|
tool,
|
|
179
179
|
args,
|
|
@@ -209,6 +209,24 @@ function buildServer(
|
|
|
209
209
|
content: [{ type: 'text', text: `${text}\n(backend: ${usedBackend})` }],
|
|
210
210
|
};
|
|
211
211
|
}
|
|
212
|
+
try {
|
|
213
|
+
const cache = persistRuntimeBackendsCache({
|
|
214
|
+
tool,
|
|
215
|
+
assetRoot,
|
|
216
|
+
usedBackend,
|
|
217
|
+
attempts,
|
|
218
|
+
});
|
|
219
|
+
if (cache) {
|
|
220
|
+
tool.preferredOrder = cache.preferredOrder;
|
|
221
|
+
log(
|
|
222
|
+
` learned backend order for ${tool.workflow.toolName}: ${cache.preferredOrder.join(' → ')}`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
} catch (err) {
|
|
226
|
+
log(
|
|
227
|
+
` warning: could not persist backend order for ${tool.workflow.toolName}: ${err instanceof Error ? err.message : String(err)}`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
212
230
|
const text =
|
|
213
231
|
typeof result.data === 'string' ? result.data : JSON.stringify(result.data, null, 2);
|
|
214
232
|
return { content: [{ type: 'text', text: `${text}\n\n(backend: ${usedBackend})` }] };
|
|
@@ -250,12 +268,19 @@ export async function runMcpServer(opts: RunMcpServerOptions): Promise<void> {
|
|
|
250
268
|
const discovered = await discoverTools(assetRoot, opts.site, '[imprint mcp]');
|
|
251
269
|
const tools: ResolvedTool[] = discovered.map((t) => {
|
|
252
270
|
const playbookPath = pathResolve(t.dir, 'playbook.yaml');
|
|
253
|
-
const
|
|
271
|
+
const cacheStatus = loadBackendsCacheStatus(t.site, assetRoot, t.dir, {
|
|
272
|
+
toolName: t.workflow.toolName,
|
|
273
|
+
});
|
|
274
|
+
if (cacheStatus.status === 'stale' || cacheStatus.status === 'invalid') {
|
|
275
|
+
log(
|
|
276
|
+
` ${t.workflow.toolName}: ${cacheStatus.status} backends.json (${cacheStatus.reason}); run \`${cacheStatus.remediation}\``,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
254
279
|
return {
|
|
255
280
|
...t,
|
|
256
281
|
inputSchema: buildJsonSchema(t.workflow.parameters),
|
|
257
282
|
playbookPath: existsSync(playbookPath) ? playbookPath : undefined,
|
|
258
|
-
preferredOrder: cache
|
|
283
|
+
preferredOrder: cacheStatus.status === 'ok' ? cacheStatus.cache.preferredOrder : undefined,
|
|
259
284
|
};
|
|
260
285
|
});
|
|
261
286
|
if (tools.length === 0) {
|
|
@@ -30,6 +30,10 @@ interface RunPlaybookOptions {
|
|
|
30
30
|
headed?: boolean;
|
|
31
31
|
/** Per-step timeout in ms. Default 30000. */
|
|
32
32
|
stepTimeoutMs?: number;
|
|
33
|
+
/** Whole-playbook timeout in ms. Default unbounded for direct playbook runs. */
|
|
34
|
+
maxDurationMs?: number;
|
|
35
|
+
/** Timeout for diagnostic screenshots in ms. Default 5000. */
|
|
36
|
+
screenshotTimeoutMs?: number;
|
|
33
37
|
/** Screenshot after every step (not just on failure). */
|
|
34
38
|
trace?: boolean;
|
|
35
39
|
/** Inject a Playwright Page for tests. */
|
|
@@ -44,6 +48,8 @@ interface RunPlaybookOptions {
|
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
const log = createLog('playbook');
|
|
51
|
+
const DEFAULT_STEP_TIMEOUT_MS = 30000;
|
|
52
|
+
const DEFAULT_SCREENSHOT_TIMEOUT_MS = 5000;
|
|
47
53
|
|
|
48
54
|
export async function runPlaybook(opts: RunPlaybookOptions): Promise<ToolResult> {
|
|
49
55
|
let playbook: Playbook;
|
|
@@ -57,7 +63,10 @@ export async function runPlaybook(opts: RunPlaybookOptions): Promise<ToolResult>
|
|
|
57
63
|
// Generous default — Akamai sensor JS, A/B loaders, lazy bundles all
|
|
58
64
|
// need real time to settle. Tight timeouts make broken sites look
|
|
59
65
|
// worse than they are.
|
|
60
|
-
const stepTimeoutMs = opts.stepTimeoutMs
|
|
66
|
+
const stepTimeoutMs = positiveMs(opts.stepTimeoutMs, DEFAULT_STEP_TIMEOUT_MS);
|
|
67
|
+
const screenshotTimeoutMs = positiveMs(opts.screenshotTimeoutMs, DEFAULT_SCREENSHOT_TIMEOUT_MS);
|
|
68
|
+
const deadlineAt =
|
|
69
|
+
opts.maxDurationMs !== undefined ? Date.now() + positiveMs(opts.maxDurationMs, 1) : null;
|
|
61
70
|
|
|
62
71
|
let browser: Browser | undefined;
|
|
63
72
|
let context: BrowserContext | undefined;
|
|
@@ -137,19 +146,42 @@ export async function runPlaybook(opts: RunPlaybookOptions): Promise<ToolResult>
|
|
|
137
146
|
|
|
138
147
|
for (const [i, step] of playbook.steps.entries()) {
|
|
139
148
|
lastStep = i + 1;
|
|
149
|
+
const budgetMs = budgetedTimeoutMs(
|
|
150
|
+
stepTimeoutMs,
|
|
151
|
+
deadlineAt,
|
|
152
|
+
`Playbook exceeded max duration before step ${lastStep}`,
|
|
153
|
+
);
|
|
140
154
|
log(`step ${i + 1}/${playbook.steps.length}: ${step.action}`);
|
|
141
|
-
await
|
|
155
|
+
await withTimeout(
|
|
156
|
+
executeStep(page, step, params, budgetMs),
|
|
157
|
+
budgetMs,
|
|
158
|
+
`Playbook step ${lastStep}/${playbook.steps.length} (${step.action})`,
|
|
159
|
+
);
|
|
142
160
|
if (opts.trace) {
|
|
143
|
-
const traceShot = await screenshot(
|
|
161
|
+
const traceShot = await screenshot(
|
|
162
|
+
page,
|
|
163
|
+
`${playbook.toolName}-trace`,
|
|
164
|
+
lastStep,
|
|
165
|
+
screenshotTimeoutMs,
|
|
166
|
+
);
|
|
144
167
|
log(` url=${page.url()}`);
|
|
145
168
|
if (traceShot) log(` trace screenshot: ${traceShot}`);
|
|
146
169
|
}
|
|
147
170
|
}
|
|
148
|
-
|
|
171
|
+
const bodyReadBudgetMs = budgetedTimeoutMs(
|
|
172
|
+
stepTimeoutMs,
|
|
173
|
+
deadlineAt,
|
|
174
|
+
'Playbook exceeded max duration while reading captured responses',
|
|
175
|
+
);
|
|
176
|
+
await withTimeout(
|
|
177
|
+
Promise.allSettled(pendingBodyReads),
|
|
178
|
+
bodyReadBudgetMs,
|
|
179
|
+
'Playbook captured-response drain',
|
|
180
|
+
);
|
|
149
181
|
const data = await extractResult(page, playbook.result, captured);
|
|
150
182
|
return { ok: true, data };
|
|
151
183
|
} catch (err) {
|
|
152
|
-
const screenshotPath = await screenshot(page, playbook.toolName, lastStep);
|
|
184
|
+
const screenshotPath = await screenshot(page, playbook.toolName, lastStep, screenshotTimeoutMs);
|
|
153
185
|
const suffix = screenshotPath ? `\nscreenshot: ${screenshotPath}` : '';
|
|
154
186
|
const errStr = errMsg(err);
|
|
155
187
|
// Classify the failure mode honestly: a missing locator, a step
|
|
@@ -161,9 +193,10 @@ export async function runPlaybook(opts: RunPlaybookOptions): Promise<ToolResult>
|
|
|
161
193
|
// bug, which over-attributes drift to defects. Map known
|
|
162
194
|
// transient-shape errors to NETWORK so they count as `infra`
|
|
163
195
|
// (re-runnable) rather than `tool_broken` (permanent defect).
|
|
164
|
-
const isTransient =
|
|
165
|
-
|
|
166
|
-
|
|
196
|
+
const isTransient =
|
|
197
|
+
/No locator matched|Timeout \d+ms exceeded|timed out after|exceeded max duration|forResponse|waiting for/i.test(
|
|
198
|
+
errStr,
|
|
199
|
+
);
|
|
167
200
|
return {
|
|
168
201
|
ok: false,
|
|
169
202
|
error: isTransient ? 'NETWORK' : 'BAD_RESPONSE',
|
|
@@ -177,19 +210,58 @@ export async function runPlaybook(opts: RunPlaybookOptions): Promise<ToolResult>
|
|
|
177
210
|
}
|
|
178
211
|
}
|
|
179
212
|
|
|
180
|
-
async function screenshot(
|
|
213
|
+
async function screenshot(
|
|
214
|
+
page: Page,
|
|
215
|
+
toolName: string,
|
|
216
|
+
stepNum: number,
|
|
217
|
+
timeoutMs: number,
|
|
218
|
+
): Promise<string | null> {
|
|
181
219
|
try {
|
|
182
220
|
const { tmpdir } = await import('node:os');
|
|
183
221
|
const { join } = await import('node:path');
|
|
184
222
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
185
223
|
const path = join(tmpdir(), `imprint-playbook-${toolName}-step${stepNum}-${ts}.png`);
|
|
186
|
-
await page.screenshot({ path, fullPage: true });
|
|
224
|
+
await withTimeout(page.screenshot({ path, fullPage: true }), timeoutMs, 'Playbook screenshot');
|
|
187
225
|
return path;
|
|
188
226
|
} catch {
|
|
189
227
|
return null;
|
|
190
228
|
}
|
|
191
229
|
}
|
|
192
230
|
|
|
231
|
+
function positiveMs(value: number | undefined, fallback: number): number {
|
|
232
|
+
if (value === undefined) return fallback;
|
|
233
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function budgetedTimeoutMs(
|
|
237
|
+
configuredMs: number,
|
|
238
|
+
deadlineAt: number | null,
|
|
239
|
+
errorMessage: string,
|
|
240
|
+
): number {
|
|
241
|
+
if (deadlineAt === null) return configuredMs;
|
|
242
|
+
const remainingMs = deadlineAt - Date.now();
|
|
243
|
+
if (remainingMs <= 0) throw new Error(errorMessage);
|
|
244
|
+
return Math.max(1, Math.min(configuredMs, Math.floor(remainingMs)));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
248
|
+
const boundedMs = positiveMs(timeoutMs, 1);
|
|
249
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
250
|
+
try {
|
|
251
|
+
return await Promise.race([
|
|
252
|
+
promise,
|
|
253
|
+
new Promise<never>((_resolve, reject) => {
|
|
254
|
+
timer = setTimeout(
|
|
255
|
+
() => reject(new Error(`${label} timed out after ${boundedMs}ms`)),
|
|
256
|
+
boundedMs,
|
|
257
|
+
);
|
|
258
|
+
}),
|
|
259
|
+
]);
|
|
260
|
+
} finally {
|
|
261
|
+
if (timer) clearTimeout(timer);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
193
265
|
async function loadPlaybook(input: string | Playbook): Promise<Playbook> {
|
|
194
266
|
if (typeof input !== 'string') return input;
|
|
195
267
|
if (!existsSync(input)) {
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
-
import { resolve as pathResolve } from 'node:path';
|
|
10
|
+
import { basename, resolve as pathResolve } from 'node:path';
|
|
11
11
|
import { runWithLadder } from './backend-ladder.ts';
|
|
12
|
+
import type { CdpBrowserFetch } from './cdp-browser-fetch.ts';
|
|
12
13
|
import { createLog } from './log.ts';
|
|
13
14
|
import { imprintHomeDir } from './paths.ts';
|
|
14
15
|
import { availableSitesHint } from './sites.ts';
|
|
@@ -42,6 +43,41 @@ interface ProbeBackendsResult {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
const log = createLog('probe');
|
|
46
|
+
const DEFAULT_PREFERRED_MAX_MS = 90_000;
|
|
47
|
+
|
|
48
|
+
type BackendProbeCandidate = {
|
|
49
|
+
backend: ConcreteBackend;
|
|
50
|
+
durationMs: number;
|
|
51
|
+
rankingDurationMs?: number;
|
|
52
|
+
coldDurationMs?: number;
|
|
53
|
+
warmDurationMs?: number;
|
|
54
|
+
tooSlow: boolean;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type BackendRuntimeAttempt = {
|
|
58
|
+
backend: ConcreteBackend;
|
|
59
|
+
outcome: 'ok' | 'escalate' | 'failed' | 'unavailable';
|
|
60
|
+
detail: string;
|
|
61
|
+
durationMs: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type BackendsCacheStatus =
|
|
65
|
+
| {
|
|
66
|
+
status: 'missing';
|
|
67
|
+
path: string | null;
|
|
68
|
+
remediation: string;
|
|
69
|
+
}
|
|
70
|
+
| {
|
|
71
|
+
status: 'ok';
|
|
72
|
+
path: string;
|
|
73
|
+
cache: BackendsCache;
|
|
74
|
+
}
|
|
75
|
+
| {
|
|
76
|
+
status: 'stale' | 'invalid';
|
|
77
|
+
path: string;
|
|
78
|
+
reason: string;
|
|
79
|
+
remediation: string;
|
|
80
|
+
};
|
|
45
81
|
|
|
46
82
|
export async function probeBackends(opts: ProbeBackendsOptions): Promise<ProbeBackendsResult> {
|
|
47
83
|
const assetRoot = opts.assetRoot ?? imprintHomeDir();
|
|
@@ -59,7 +95,36 @@ export async function probeBackends(opts: ProbeBackendsOptions): Promise<ProbeBa
|
|
|
59
95
|
`No generated tool found for site "${opts.site}".\n${availableSitesHint(assetRoot, opts.site)}\n→ run \`imprint teach ${opts.site}\` or \`imprint emit ~/.imprint/${opts.site}/<toolName>/workflow.json\` first.`,
|
|
60
96
|
);
|
|
61
97
|
}
|
|
62
|
-
|
|
98
|
+
return await probeResolvedTool(opts, assetRoot, tool, opts.outPath);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function probeAllBackends(
|
|
102
|
+
opts: Omit<ProbeBackendsOptions, 'outPath' | 'toolName'>,
|
|
103
|
+
): Promise<ProbeBackendsResult[]> {
|
|
104
|
+
const assetRoot = opts.assetRoot ?? imprintHomeDir();
|
|
105
|
+
const discovered = await discoverTools(assetRoot, opts.site, '[imprint probe]');
|
|
106
|
+
if (discovered.length === 0) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`No generated tools found for site "${opts.site}".\n${availableSitesHint(assetRoot, opts.site)}\n→ run \`imprint teach ${opts.site}\` or \`imprint emit ~/.imprint/${opts.site}/<toolName>/workflow.json\` first.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const results: ProbeBackendsResult[] = [];
|
|
113
|
+
for (const tool of [...discovered].sort((a, b) =>
|
|
114
|
+
a.workflow.toolName.localeCompare(b.workflow.toolName),
|
|
115
|
+
)) {
|
|
116
|
+
results.push(await probeResolvedTool(opts, assetRoot, tool));
|
|
117
|
+
}
|
|
118
|
+
return results;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function probeResolvedTool(
|
|
122
|
+
opts: Pick<ProbeBackendsOptions, 'site' | 'paramOverrides'>,
|
|
123
|
+
assetRoot: string,
|
|
124
|
+
tool: ResolvedTool,
|
|
125
|
+
explicitOutPath?: string,
|
|
126
|
+
): Promise<ProbeBackendsResult> {
|
|
127
|
+
const outPath = explicitOutPath ?? pathResolve(tool.dir, 'backends.json');
|
|
63
128
|
|
|
64
129
|
const params = resolveParams(tool, opts.paramOverrides);
|
|
65
130
|
|
|
@@ -72,59 +137,96 @@ export async function probeBackends(opts: ProbeBackendsOptions): Promise<ProbeBa
|
|
|
72
137
|
// falls through fetch-bootstrap (~30-60s) before reaching the spliced-in
|
|
73
138
|
// cdp-replay rung, wasting time on every call.
|
|
74
139
|
const stealthCache = new Map<string, StealthFetch>();
|
|
140
|
+
const cdpPool = new Map<string, CdpBrowserFetch>();
|
|
75
141
|
const allBackends: ConcreteBackend[] = workflowNeedsBootstrap(tool.workflow)
|
|
76
142
|
? ['fetch', 'fetch-bootstrap', 'cdp-replay', 'stealth-fetch', 'playbook']
|
|
77
143
|
: ['fetch', 'stealth-fetch', 'playbook'];
|
|
78
144
|
const results: BackendsCache['results'] = {};
|
|
79
|
-
const working:
|
|
80
|
-
|
|
81
|
-
for (const backend of allBackends) {
|
|
82
|
-
log(`probing ${backend}…`);
|
|
83
|
-
const t0 = Date.now();
|
|
84
|
-
const { result, attempts } = await runWithLadder(
|
|
85
|
-
[backend],
|
|
86
|
-
tool,
|
|
87
|
-
params,
|
|
88
|
-
assetRoot,
|
|
89
|
-
stealthCache,
|
|
90
|
-
);
|
|
91
|
-
const durationMs = Date.now() - t0;
|
|
92
|
-
const attempt = attempts[0];
|
|
145
|
+
const working: BackendProbeCandidate[] = [];
|
|
146
|
+
const preferredMaxMs = preferredBackendMaxMs();
|
|
93
147
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
148
|
+
try {
|
|
149
|
+
for (const backend of allBackends) {
|
|
150
|
+
log(`probing ${backend}…`);
|
|
151
|
+
const t0 = Date.now();
|
|
152
|
+
const { result, attempts } = await runWithLadder(
|
|
153
|
+
[backend],
|
|
154
|
+
tool,
|
|
155
|
+
params,
|
|
156
|
+
assetRoot,
|
|
157
|
+
stealthCache,
|
|
158
|
+
backend === 'cdp-replay' ? { cdpPool, skipBootstrapSplice: true } : undefined,
|
|
159
|
+
);
|
|
160
|
+
const durationMs = Date.now() - t0;
|
|
161
|
+
const attempt = attempts[0];
|
|
98
162
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
163
|
+
if (!attempt) {
|
|
164
|
+
results[backend] = { outcome: 'skipped', detail: 'no attempt recorded' };
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
104
167
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
168
|
+
if (attempt.outcome === 'unavailable') {
|
|
169
|
+
results[backend] = { outcome: 'unavailable', detail: attempt.detail };
|
|
170
|
+
log(` ${backend}: unavailable (${attempt.detail})`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
111
173
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
durationMs
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
174
|
+
if (result.ok) {
|
|
175
|
+
const warm =
|
|
176
|
+
backend === 'cdp-replay'
|
|
177
|
+
? await probeWarmCdpReplay(tool, params, assetRoot, stealthCache, cdpPool)
|
|
178
|
+
: null;
|
|
179
|
+
const tooSlow = durationMs > preferredMaxMs;
|
|
180
|
+
const rankingDurationMs = warm?.ok ? warm.durationMs : durationMs;
|
|
181
|
+
const detailParts: string[] = [];
|
|
182
|
+
if (tooSlow)
|
|
183
|
+
detailParts.push(`cold start exceeded preferred backend threshold ${preferredMaxMs}ms`);
|
|
184
|
+
if (warm?.ok) detailParts.push(`warm cdp-replay succeeded in ${warm.durationMs}ms`);
|
|
185
|
+
else if (warm) detailParts.push(`warm cdp-replay failed: ${warm.detail}`);
|
|
186
|
+
results[backend] = {
|
|
187
|
+
outcome: 'ok',
|
|
188
|
+
durationMs,
|
|
189
|
+
...(backend === 'cdp-replay'
|
|
190
|
+
? {
|
|
191
|
+
coldDurationMs: durationMs,
|
|
192
|
+
...(warm?.ok ? { warmDurationMs: warm.durationMs, rankingDurationMs } : {}),
|
|
193
|
+
}
|
|
194
|
+
: {}),
|
|
195
|
+
...(tooSlow ? { tooSlow: true } : {}),
|
|
196
|
+
...(detailParts.length ? { detail: detailParts.join('; ') } : {}),
|
|
197
|
+
};
|
|
198
|
+
working.push({
|
|
199
|
+
backend,
|
|
200
|
+
durationMs,
|
|
201
|
+
...(backend === 'cdp-replay' ? { coldDurationMs: durationMs } : {}),
|
|
202
|
+
...(warm?.ok ? { warmDurationMs: warm.durationMs, rankingDurationMs } : {}),
|
|
203
|
+
tooSlow,
|
|
204
|
+
});
|
|
205
|
+
log(
|
|
206
|
+
` ${backend}: OK in ${durationMs}ms${warm?.ok ? ` (warm ${warm.durationMs}ms)` : ''}${tooSlow ? ' (cold slow)' : ''}`,
|
|
207
|
+
);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (result.error === 'FORBIDDEN') {
|
|
212
|
+
results[backend] = {
|
|
213
|
+
outcome: 'forbidden',
|
|
214
|
+
durationMs,
|
|
215
|
+
detail: result.message.slice(0, 200),
|
|
216
|
+
};
|
|
217
|
+
log(` ${backend}: FORBIDDEN`);
|
|
218
|
+
} else {
|
|
219
|
+
results[backend] = {
|
|
220
|
+
outcome: 'failed',
|
|
221
|
+
durationMs,
|
|
222
|
+
error: result.error,
|
|
223
|
+
detail: result.message.slice(0, 200),
|
|
224
|
+
};
|
|
225
|
+
log(` ${backend}: ${result.error} — ${result.message.slice(0, 100)}`);
|
|
226
|
+
}
|
|
127
227
|
}
|
|
228
|
+
} finally {
|
|
229
|
+
await closeProbeCdpPool(cdpPool);
|
|
128
230
|
}
|
|
129
231
|
|
|
130
232
|
if (working.length === 0) {
|
|
@@ -135,23 +237,92 @@ export async function probeBackends(opts: ProbeBackendsOptions): Promise<ProbeBa
|
|
|
135
237
|
);
|
|
136
238
|
}
|
|
137
239
|
|
|
240
|
+
const preferredOrder = rankSuccessfulBackends(working);
|
|
138
241
|
const cache: BackendsCache = {
|
|
139
242
|
probedAt: new Date().toISOString(),
|
|
140
243
|
imprintVersion: VERSION,
|
|
141
244
|
schemaVersion: 2,
|
|
142
245
|
workflowHash: workflowHash(tool.workflow),
|
|
143
246
|
capabilityHash: capabilityHash(tool.workflow),
|
|
144
|
-
preferredOrder
|
|
247
|
+
preferredOrder,
|
|
145
248
|
results,
|
|
146
249
|
};
|
|
147
250
|
BackendsCacheSchema.parse(cache); // catch schema drift early
|
|
148
251
|
|
|
149
252
|
writeFileSync(outPath, `${JSON.stringify(cache, null, 2)}\n`);
|
|
150
|
-
log(`wrote ${outPath} — preferred: ${
|
|
253
|
+
log(`wrote ${outPath} — preferred: ${preferredOrder.join(' → ')}`);
|
|
151
254
|
|
|
152
255
|
return { cache, outPath };
|
|
153
256
|
}
|
|
154
257
|
|
|
258
|
+
export function rankSuccessfulBackends(candidates: BackendProbeCandidate[]): ConcreteBackend[] {
|
|
259
|
+
return [...candidates]
|
|
260
|
+
.sort((a, b) => {
|
|
261
|
+
if (a.tooSlow !== b.tooSlow) return a.tooSlow ? 1 : -1;
|
|
262
|
+
return effectiveRankingDuration(a) - effectiveRankingDuration(b);
|
|
263
|
+
})
|
|
264
|
+
.map((c) => c.backend);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function effectiveRankingDuration(candidate: BackendProbeCandidate): number {
|
|
268
|
+
return candidate.rankingDurationMs ?? candidate.warmDurationMs ?? candidate.durationMs;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function backendResultTooSlow(result: BackendsCache['results'][string] | undefined): boolean {
|
|
272
|
+
return result?.outcome === 'ok' && result.tooSlow === true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function invalidPreferredOrderReason(cache: BackendsCache): string | null {
|
|
276
|
+
for (const backend of cache.preferredOrder) {
|
|
277
|
+
const result = cache.results[backend];
|
|
278
|
+
if (backend === 'playbook' && result?.outcome !== 'ok') {
|
|
279
|
+
return 'preferredOrder includes playbook without a successful playbook result';
|
|
280
|
+
}
|
|
281
|
+
if (result && result.outcome !== 'ok') {
|
|
282
|
+
return `preferredOrder includes ${backend} with ${result.outcome} result`;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function existingBackendUsable(
|
|
289
|
+
backend: ConcreteBackend,
|
|
290
|
+
result: BackendsCache['results'][string] | undefined,
|
|
291
|
+
): boolean {
|
|
292
|
+
if (!result) return backend !== 'playbook';
|
|
293
|
+
return result.outcome === 'ok';
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function probeWarmCdpReplay(
|
|
297
|
+
tool: ResolvedTool,
|
|
298
|
+
params: Record<string, string | number | boolean>,
|
|
299
|
+
assetRoot: string,
|
|
300
|
+
stealthCache: Map<string, StealthFetch>,
|
|
301
|
+
cdpPool: Map<string, CdpBrowserFetch>,
|
|
302
|
+
): Promise<{ ok: true; durationMs: number } | { ok: false; detail: string } | null> {
|
|
303
|
+
if (!cdpPool.has(tool.site)) return null;
|
|
304
|
+
log('probing cdp-replay warm reuse…');
|
|
305
|
+
const t0 = Date.now();
|
|
306
|
+
const { result } = await runWithLadder(['cdp-replay'], tool, params, assetRoot, stealthCache, {
|
|
307
|
+
cdpPool,
|
|
308
|
+
skipBootstrapSplice: true,
|
|
309
|
+
});
|
|
310
|
+
const durationMs = Date.now() - t0;
|
|
311
|
+
if (result.ok) return { ok: true, durationMs };
|
|
312
|
+
return { ok: false, detail: `${result.error}: ${result.message.slice(0, 160)}` };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function closeProbeCdpPool(cdpPool: Map<string, CdpBrowserFetch>): Promise<void> {
|
|
316
|
+
const sessions = [...cdpPool.values()];
|
|
317
|
+
cdpPool.clear();
|
|
318
|
+
await Promise.allSettled(sessions.map((session) => session.close()));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function preferredBackendMaxMs(): number {
|
|
322
|
+
const raw = Number(process.env.IMPRINT_BACKEND_PREFERRED_MAX_MS ?? DEFAULT_PREFERRED_MAX_MS);
|
|
323
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_PREFERRED_MAX_MS;
|
|
324
|
+
}
|
|
325
|
+
|
|
155
326
|
function workflowNeedsBootstrap(workflow: ResolvedTool['workflow']): boolean {
|
|
156
327
|
if (workflow.bootstrap) return true;
|
|
157
328
|
return workflow.requests.some((r) =>
|
|
@@ -177,16 +348,19 @@ function capabilityHash(workflow: ResolvedTool['workflow']): string {
|
|
|
177
348
|
return createHash('sha256').update(JSON.stringify(caps)).digest('hex');
|
|
178
349
|
}
|
|
179
350
|
|
|
180
|
-
/** Read backends.json.
|
|
181
|
-
*
|
|
182
|
-
|
|
351
|
+
/** Read backends.json with status information. Runtime can still fall back to
|
|
352
|
+
* the default ladder, while status commands can explain why a cache was not
|
|
353
|
+
* usable. */
|
|
354
|
+
export function loadBackendsCacheStatus(
|
|
183
355
|
site: string,
|
|
184
356
|
_assetRoot: string,
|
|
185
357
|
toolDir?: string,
|
|
186
|
-
|
|
187
|
-
|
|
358
|
+
opts: { warn?: boolean; toolName?: string } = {},
|
|
359
|
+
): BackendsCacheStatus {
|
|
360
|
+
const remediation = backendsCacheRemediation(site, opts.toolName ?? toolDirName(toolDir));
|
|
361
|
+
if (!toolDir) return { status: 'missing', path: null, remediation };
|
|
188
362
|
const path = pathResolve(toolDir, 'backends.json');
|
|
189
|
-
if (!existsSync(path)) return
|
|
363
|
+
if (!existsSync(path)) return { status: 'missing', path, remediation };
|
|
190
364
|
try {
|
|
191
365
|
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
192
366
|
const parsed = BackendsCacheSchema.parse(raw);
|
|
@@ -195,20 +369,131 @@ export function loadBackendsCache(
|
|
|
195
369
|
if (existsSync(workflowPath)) {
|
|
196
370
|
const currentHash = workflowHashSync(readFileSync(workflowPath, 'utf8'));
|
|
197
371
|
if (currentHash !== parsed.workflowHash) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
372
|
+
const reason = 'workflow hash changed';
|
|
373
|
+
if (opts.warn !== false) {
|
|
374
|
+
process.stderr.write(
|
|
375
|
+
`[imprint] backends.json at ${path} is stale for current workflow — ignoring (run \`${remediation}\` to regenerate)\n`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
return { status: 'stale', path, reason, remediation };
|
|
202
379
|
}
|
|
203
380
|
}
|
|
204
381
|
}
|
|
205
|
-
|
|
382
|
+
const invalidPreferredReason = invalidPreferredOrderReason(parsed);
|
|
383
|
+
if (invalidPreferredReason) {
|
|
384
|
+
if (opts.warn !== false) {
|
|
385
|
+
process.stderr.write(
|
|
386
|
+
`[imprint] backends.json at ${path} has unsafe preferred backends — ignoring (run \`${remediation}\` to regenerate): ${invalidPreferredReason}\n`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
return { status: 'invalid', path, reason: invalidPreferredReason, remediation };
|
|
390
|
+
}
|
|
391
|
+
return { status: 'ok', path, cache: parsed };
|
|
206
392
|
} catch (err) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
393
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
394
|
+
if (opts.warn !== false) {
|
|
395
|
+
process.stderr.write(
|
|
396
|
+
`[imprint] backends.json at ${path} failed to parse — ignoring (run \`${remediation}\` to regenerate): ${reason}\n`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
return { status: 'invalid', path, reason, remediation };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Read backends.json. Returns null on missing/malformed — runtime
|
|
404
|
+
* falls back to the default ladder; a stale cache must never break cron. */
|
|
405
|
+
export function loadBackendsCache(
|
|
406
|
+
site: string,
|
|
407
|
+
_assetRoot: string,
|
|
408
|
+
toolDir?: string,
|
|
409
|
+
): BackendsCache | null {
|
|
410
|
+
const status = loadBackendsCacheStatus(site, _assetRoot, toolDir);
|
|
411
|
+
return status.status === 'ok' ? status.cache : null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function persistRuntimeBackendsCache(opts: {
|
|
415
|
+
tool: ResolvedTool;
|
|
416
|
+
assetRoot: string;
|
|
417
|
+
usedBackend: ConcreteBackend;
|
|
418
|
+
attempts: BackendRuntimeAttempt[];
|
|
419
|
+
}): BackendsCache | null {
|
|
420
|
+
const status = loadBackendsCacheStatus(opts.tool.site, opts.assetRoot, opts.tool.dir, {
|
|
421
|
+
warn: false,
|
|
422
|
+
toolName: opts.tool.workflow.toolName,
|
|
423
|
+
});
|
|
424
|
+
const results: BackendsCache['results'] =
|
|
425
|
+
status.status === 'ok' ? { ...status.cache.results } : {};
|
|
426
|
+
|
|
427
|
+
for (const attempt of opts.attempts) {
|
|
428
|
+
if (attempt.outcome === 'ok') {
|
|
429
|
+
const tooSlow = attempt.durationMs > preferredBackendMaxMs();
|
|
430
|
+
results[attempt.backend] = {
|
|
431
|
+
outcome: 'ok',
|
|
432
|
+
durationMs: attempt.durationMs,
|
|
433
|
+
...(tooSlow
|
|
434
|
+
? {
|
|
435
|
+
tooSlow: true,
|
|
436
|
+
detail: `exceeded preferred backend threshold ${preferredBackendMaxMs()}ms`,
|
|
437
|
+
}
|
|
438
|
+
: {}),
|
|
439
|
+
};
|
|
440
|
+
} else if (attempt.outcome === 'unavailable') {
|
|
441
|
+
results[attempt.backend] = { outcome: 'unavailable', detail: attempt.detail };
|
|
442
|
+
} else if (attempt.detail.startsWith('FORBIDDEN:')) {
|
|
443
|
+
results[attempt.backend] = {
|
|
444
|
+
outcome: 'forbidden',
|
|
445
|
+
durationMs: attempt.durationMs,
|
|
446
|
+
detail: attempt.detail.slice(0, 200),
|
|
447
|
+
};
|
|
448
|
+
} else {
|
|
449
|
+
const error = attempt.detail.split(':')[0] || 'UNKNOWN';
|
|
450
|
+
results[attempt.backend] = {
|
|
451
|
+
outcome: 'failed',
|
|
452
|
+
durationMs: attempt.durationMs,
|
|
453
|
+
error,
|
|
454
|
+
detail: attempt.detail.slice(0, 200),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
211
457
|
}
|
|
458
|
+
|
|
459
|
+
const existingPreferred = status.status === 'ok' ? status.cache.preferredOrder : [];
|
|
460
|
+
const observedOkAttempts = opts.attempts
|
|
461
|
+
.filter((a) => a.outcome === 'ok')
|
|
462
|
+
.sort((a, b) => a.durationMs - b.durationMs);
|
|
463
|
+
const observedOk = observedOkAttempts.map((a) => a.backend);
|
|
464
|
+
const slowObservedOk = observedOkAttempts
|
|
465
|
+
.filter((a) => a.durationMs > preferredBackendMaxMs())
|
|
466
|
+
.map((a) => a.backend);
|
|
467
|
+
const fastObservedOk = observedOk.filter((backend) => !slowObservedOk.includes(backend));
|
|
468
|
+
const usedOkAttempt = observedOkAttempts.find((a) => a.backend === opts.usedBackend);
|
|
469
|
+
const usedBackendTooSlow =
|
|
470
|
+
usedOkAttempt !== undefined && usedOkAttempt.durationMs > preferredBackendMaxMs();
|
|
471
|
+
const existingUsable = existingPreferred.filter((backend) =>
|
|
472
|
+
existingBackendUsable(backend, results[backend]),
|
|
473
|
+
);
|
|
474
|
+
const existingFast = existingUsable.filter((backend) => !backendResultTooSlow(results[backend]));
|
|
475
|
+
const existingSlow = existingUsable.filter((backend) => backendResultTooSlow(results[backend]));
|
|
476
|
+
const preferredOrder = uniqueBackends([
|
|
477
|
+
...(usedOkAttempt && !usedBackendTooSlow ? [opts.usedBackend] : []),
|
|
478
|
+
...existingFast,
|
|
479
|
+
...fastObservedOk,
|
|
480
|
+
...existingSlow,
|
|
481
|
+
...slowObservedOk,
|
|
482
|
+
...(usedOkAttempt && usedBackendTooSlow ? [opts.usedBackend] : []),
|
|
483
|
+
]);
|
|
484
|
+
const cache: BackendsCache = {
|
|
485
|
+
probedAt: new Date().toISOString(),
|
|
486
|
+
imprintVersion: VERSION,
|
|
487
|
+
schemaVersion: 2,
|
|
488
|
+
workflowHash: workflowHash(opts.tool.workflow),
|
|
489
|
+
capabilityHash: capabilityHash(opts.tool.workflow),
|
|
490
|
+
preferredOrder,
|
|
491
|
+
results,
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
BackendsCacheSchema.parse(cache);
|
|
495
|
+
writeFileSync(pathResolve(opts.tool.dir, 'backends.json'), `${JSON.stringify(cache, null, 2)}\n`);
|
|
496
|
+
return cache;
|
|
212
497
|
}
|
|
213
498
|
|
|
214
499
|
function workflowHashSync(workflowJson: string): string {
|
|
@@ -217,6 +502,27 @@ function workflowHashSync(workflowJson: string): string {
|
|
|
217
502
|
.digest('hex');
|
|
218
503
|
}
|
|
219
504
|
|
|
505
|
+
function backendsCacheRemediation(site: string, toolName?: string): string {
|
|
506
|
+
return toolName
|
|
507
|
+
? `imprint probe-backends ${site} --tool ${toolName}`
|
|
508
|
+
: `imprint probe-backends ${site}`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function toolDirName(toolDir?: string): string | undefined {
|
|
512
|
+
return toolDir ? basename(toolDir) : undefined;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function uniqueBackends(backends: ConcreteBackend[]): ConcreteBackend[] {
|
|
516
|
+
const seen = new Set<ConcreteBackend>();
|
|
517
|
+
const out: ConcreteBackend[] = [];
|
|
518
|
+
for (const backend of backends) {
|
|
519
|
+
if (seen.has(backend)) continue;
|
|
520
|
+
seen.add(backend);
|
|
521
|
+
out.push(backend);
|
|
522
|
+
}
|
|
523
|
+
return out;
|
|
524
|
+
}
|
|
525
|
+
|
|
220
526
|
/** Param priority: caller overrides → cron.json → workflow defaults. */
|
|
221
527
|
function resolveParams(
|
|
222
528
|
tool: ResolvedTool,
|
package/src/imprint/types.ts
CHANGED
|
@@ -380,6 +380,18 @@ const BackendProbeResultSchema = z.discriminatedUnion('outcome', [
|
|
|
380
380
|
z.object({
|
|
381
381
|
outcome: z.literal('ok'),
|
|
382
382
|
durationMs: z.number(),
|
|
383
|
+
/** Optional cdp-replay cold-start measurement. `durationMs` remains the
|
|
384
|
+
* first-call duration for backward compatibility. */
|
|
385
|
+
coldDurationMs: z.number().optional(),
|
|
386
|
+
/** Optional cdp-replay warm-pool measurement from a second call against the
|
|
387
|
+
* same pooled Chrome. Used to explain why CDP may outrank stealth when its
|
|
388
|
+
* cold start is still under the operator timeout. */
|
|
389
|
+
warmDurationMs: z.number().optional(),
|
|
390
|
+
/** Effective duration used for preference ranking when it differs from the
|
|
391
|
+
* first-call duration, e.g. warm cdp-replay. */
|
|
392
|
+
rankingDurationMs: z.number().optional(),
|
|
393
|
+
tooSlow: z.boolean().optional(),
|
|
394
|
+
detail: z.string().optional(),
|
|
383
395
|
}),
|
|
384
396
|
z.object({
|
|
385
397
|
outcome: z.literal('forbidden'),
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import semver from 'semver';
|
|
3
|
+
import { VERSION } from './version.ts';
|
|
4
|
+
|
|
5
|
+
const PACKAGE_NAME = 'imprint-mcp';
|
|
6
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
7
|
+
|
|
8
|
+
interface UpdateCheckResult {
|
|
9
|
+
current: string;
|
|
10
|
+
latest: string;
|
|
11
|
+
updateAvailable: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface UpdateResult {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
from: string;
|
|
17
|
+
to: string;
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function checkForUpdate(): Promise<UpdateCheckResult | null> {
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(REGISTRY_URL, {
|
|
24
|
+
headers: { accept: 'application/json' },
|
|
25
|
+
signal: AbortSignal.timeout(5_000),
|
|
26
|
+
});
|
|
27
|
+
if (!res.ok) return null;
|
|
28
|
+
const data = (await res.json()) as { version?: string };
|
|
29
|
+
const latest = data.version;
|
|
30
|
+
if (!latest) return null;
|
|
31
|
+
return { current: VERSION, latest, updateAvailable: semver.gt(latest, VERSION) };
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const IS_COMPILED = typeof (globalThis as Record<string, unknown>).__IMPRINT_VERSION__ === 'string';
|
|
38
|
+
|
|
39
|
+
export async function performUpdate(): Promise<UpdateResult> {
|
|
40
|
+
const check = await checkForUpdate();
|
|
41
|
+
if (!check) {
|
|
42
|
+
return { ok: false, from: VERSION, to: VERSION, error: 'could not reach npm registry' };
|
|
43
|
+
}
|
|
44
|
+
if (!check.updateAvailable) {
|
|
45
|
+
return { ok: true, from: VERSION, to: VERSION };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const result = IS_COMPILED
|
|
49
|
+
? spawnSync(
|
|
50
|
+
'bash',
|
|
51
|
+
[
|
|
52
|
+
'-c',
|
|
53
|
+
'curl -fsSL https://raw.githubusercontent.com/ashaychangwani/imprint/main/scripts/install.sh | bash',
|
|
54
|
+
],
|
|
55
|
+
{ stdio: 'pipe', timeout: 60_000 },
|
|
56
|
+
)
|
|
57
|
+
: spawnSync('bun', ['install', '-g', `${PACKAGE_NAME}@latest`], {
|
|
58
|
+
stdio: 'pipe',
|
|
59
|
+
timeout: 60_000,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (result.status !== 0) {
|
|
63
|
+
const stderr = result.stderr?.toString().trim();
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
from: check.current,
|
|
67
|
+
to: check.latest,
|
|
68
|
+
error: stderr || result.error?.message || `install exited with code ${result.status}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { ok: true, from: check.current, to: check.latest };
|
|
73
|
+
}
|