imprint-mcp 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +3 -1
- package/src/cli.ts +74 -3
- package/src/imprint/backend-ladder.ts +20 -6
- 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/probe-backends.ts +346 -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.2",
|
|
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`';
|
|
@@ -317,7 +317,8 @@ export async function runWithLadder(
|
|
|
317
317
|
result = await runCdpReplay(tool, params, options?.cdpPool);
|
|
318
318
|
break;
|
|
319
319
|
case 'stealth-fetch': {
|
|
320
|
-
const
|
|
320
|
+
const paramsWithDefaults = withWorkflowDefaults(tool.workflow, params);
|
|
321
|
+
const sf = await ensureStealthFetch(tool, stealthCache, paramsWithDefaults);
|
|
321
322
|
// When the workflow declares a bootstrap block, mint its declared
|
|
322
323
|
// session-token state (CSRF cookies etc.) from the SAME stealth
|
|
323
324
|
// session that provides the transport cookies. Without this, a
|
|
@@ -327,7 +328,7 @@ export async function runWithLadder(
|
|
|
327
328
|
const initialState = tool.workflow.bootstrap
|
|
328
329
|
? await stealthBootstrapState(sf, tool.workflow.bootstrap)
|
|
329
330
|
: undefined;
|
|
330
|
-
result = await tool.toolFn(
|
|
331
|
+
result = await tool.toolFn(paramsWithDefaults, { fetchImpl: sf.fetchImpl, initialState });
|
|
331
332
|
break;
|
|
332
333
|
}
|
|
333
334
|
case 'playbook': {
|
|
@@ -1213,8 +1214,21 @@ async function stealthBootstrapState(
|
|
|
1213
1214
|
return state;
|
|
1214
1215
|
}
|
|
1215
1216
|
|
|
1216
|
-
function ensureStealthFetch(
|
|
1217
|
-
|
|
1217
|
+
async function ensureStealthFetch(
|
|
1218
|
+
tool: ResolvedTool,
|
|
1219
|
+
cache: Map<string, StealthFetch>,
|
|
1220
|
+
params: Record<string, string | number | boolean>,
|
|
1221
|
+
): Promise<StealthFetch> {
|
|
1222
|
+
const credentials = (await loadCredentialStore(tool.site)) ?? {
|
|
1223
|
+
site: tool.site,
|
|
1224
|
+
cookies: [],
|
|
1225
|
+
values: {},
|
|
1226
|
+
};
|
|
1227
|
+
const bootstrapUrl = tool.workflow.bootstrap?.url
|
|
1228
|
+
? substituteString(tool.workflow.bootstrap.url, params, credentials, [], 'url')
|
|
1229
|
+
: undefined;
|
|
1230
|
+
const cacheKey = bootstrapUrl ? `${tool.site}:${bootstrapUrl}` : tool.site;
|
|
1231
|
+
const cached = cache.get(cacheKey);
|
|
1218
1232
|
if (cached) return cached;
|
|
1219
1233
|
const sf = createStealthFetch({
|
|
1220
1234
|
baseUrl: pickBaseUrl(tool),
|
|
@@ -1223,9 +1237,9 @@ function ensureStealthFetch(tool: ResolvedTool, cache: Map<string, StealthFetch>
|
|
|
1223
1237
|
// minted in the same session as the anti-bot cookies. Otherwise the
|
|
1224
1238
|
// stealth rung can't satisfy a `${state.X}` the workflow bootstrap was
|
|
1225
1239
|
// supposed to provide, and escalation from fetch-bootstrap dead-ends.
|
|
1226
|
-
bootstrapUrl
|
|
1240
|
+
bootstrapUrl,
|
|
1227
1241
|
});
|
|
1228
|
-
cache.set(
|
|
1242
|
+
cache.set(cacheKey, sf);
|
|
1229
1243
|
return sf;
|
|
1230
1244
|
}
|
|
1231
1245
|
|
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) {
|
|
@@ -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,71 @@ 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
|
+
async function probeWarmCdpReplay(
|
|
276
|
+
tool: ResolvedTool,
|
|
277
|
+
params: Record<string, string | number | boolean>,
|
|
278
|
+
assetRoot: string,
|
|
279
|
+
stealthCache: Map<string, StealthFetch>,
|
|
280
|
+
cdpPool: Map<string, CdpBrowserFetch>,
|
|
281
|
+
): Promise<{ ok: true; durationMs: number } | { ok: false; detail: string } | null> {
|
|
282
|
+
if (!cdpPool.has(tool.site)) return null;
|
|
283
|
+
log('probing cdp-replay warm reuse…');
|
|
284
|
+
const t0 = Date.now();
|
|
285
|
+
const { result } = await runWithLadder(['cdp-replay'], tool, params, assetRoot, stealthCache, {
|
|
286
|
+
cdpPool,
|
|
287
|
+
skipBootstrapSplice: true,
|
|
288
|
+
});
|
|
289
|
+
const durationMs = Date.now() - t0;
|
|
290
|
+
if (result.ok) return { ok: true, durationMs };
|
|
291
|
+
return { ok: false, detail: `${result.error}: ${result.message.slice(0, 160)}` };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function closeProbeCdpPool(cdpPool: Map<string, CdpBrowserFetch>): Promise<void> {
|
|
295
|
+
const sessions = [...cdpPool.values()];
|
|
296
|
+
cdpPool.clear();
|
|
297
|
+
await Promise.allSettled(sessions.map((session) => session.close()));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function preferredBackendMaxMs(): number {
|
|
301
|
+
const raw = Number(process.env.IMPRINT_BACKEND_PREFERRED_MAX_MS ?? DEFAULT_PREFERRED_MAX_MS);
|
|
302
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_PREFERRED_MAX_MS;
|
|
303
|
+
}
|
|
304
|
+
|
|
155
305
|
function workflowNeedsBootstrap(workflow: ResolvedTool['workflow']): boolean {
|
|
156
306
|
if (workflow.bootstrap) return true;
|
|
157
307
|
return workflow.requests.some((r) =>
|
|
@@ -177,16 +327,19 @@ function capabilityHash(workflow: ResolvedTool['workflow']): string {
|
|
|
177
327
|
return createHash('sha256').update(JSON.stringify(caps)).digest('hex');
|
|
178
328
|
}
|
|
179
329
|
|
|
180
|
-
/** Read backends.json.
|
|
181
|
-
*
|
|
182
|
-
|
|
330
|
+
/** Read backends.json with status information. Runtime can still fall back to
|
|
331
|
+
* the default ladder, while status commands can explain why a cache was not
|
|
332
|
+
* usable. */
|
|
333
|
+
export function loadBackendsCacheStatus(
|
|
183
334
|
site: string,
|
|
184
335
|
_assetRoot: string,
|
|
185
336
|
toolDir?: string,
|
|
186
|
-
|
|
187
|
-
|
|
337
|
+
opts: { warn?: boolean; toolName?: string } = {},
|
|
338
|
+
): BackendsCacheStatus {
|
|
339
|
+
const remediation = backendsCacheRemediation(site, opts.toolName ?? toolDirName(toolDir));
|
|
340
|
+
if (!toolDir) return { status: 'missing', path: null, remediation };
|
|
188
341
|
const path = pathResolve(toolDir, 'backends.json');
|
|
189
|
-
if (!existsSync(path)) return
|
|
342
|
+
if (!existsSync(path)) return { status: 'missing', path, remediation };
|
|
190
343
|
try {
|
|
191
344
|
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
192
345
|
const parsed = BackendsCacheSchema.parse(raw);
|
|
@@ -195,20 +348,129 @@ export function loadBackendsCache(
|
|
|
195
348
|
if (existsSync(workflowPath)) {
|
|
196
349
|
const currentHash = workflowHashSync(readFileSync(workflowPath, 'utf8'));
|
|
197
350
|
if (currentHash !== parsed.workflowHash) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
351
|
+
const reason = 'workflow hash changed';
|
|
352
|
+
if (opts.warn !== false) {
|
|
353
|
+
process.stderr.write(
|
|
354
|
+
`[imprint] backends.json at ${path} is stale for current workflow — ignoring (run \`${remediation}\` to regenerate)\n`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
return { status: 'stale', path, reason, remediation };
|
|
202
358
|
}
|
|
203
359
|
}
|
|
204
360
|
}
|
|
205
|
-
return parsed;
|
|
361
|
+
return { status: 'ok', path, cache: parsed };
|
|
206
362
|
} catch (err) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
363
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
364
|
+
if (opts.warn !== false) {
|
|
365
|
+
process.stderr.write(
|
|
366
|
+
`[imprint] backends.json at ${path} failed to parse — ignoring (run \`${remediation}\` to regenerate): ${reason}\n`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return { status: 'invalid', path, reason, remediation };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Read backends.json. Returns null on missing/malformed — runtime
|
|
374
|
+
* falls back to the default ladder; a stale cache must never break cron. */
|
|
375
|
+
export function loadBackendsCache(
|
|
376
|
+
site: string,
|
|
377
|
+
_assetRoot: string,
|
|
378
|
+
toolDir?: string,
|
|
379
|
+
): BackendsCache | null {
|
|
380
|
+
const status = loadBackendsCacheStatus(site, _assetRoot, toolDir);
|
|
381
|
+
return status.status === 'ok' ? status.cache : null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function persistRuntimeBackendsCache(opts: {
|
|
385
|
+
tool: ResolvedTool;
|
|
386
|
+
assetRoot: string;
|
|
387
|
+
usedBackend: ConcreteBackend;
|
|
388
|
+
attempts: BackendRuntimeAttempt[];
|
|
389
|
+
}): BackendsCache | null {
|
|
390
|
+
const status = loadBackendsCacheStatus(opts.tool.site, opts.assetRoot, opts.tool.dir, {
|
|
391
|
+
warn: false,
|
|
392
|
+
toolName: opts.tool.workflow.toolName,
|
|
393
|
+
});
|
|
394
|
+
const results: BackendsCache['results'] =
|
|
395
|
+
status.status === 'ok' ? { ...status.cache.results } : {};
|
|
396
|
+
|
|
397
|
+
for (const attempt of opts.attempts) {
|
|
398
|
+
if (attempt.outcome === 'ok') {
|
|
399
|
+
const tooSlow = attempt.durationMs > preferredBackendMaxMs();
|
|
400
|
+
results[attempt.backend] = {
|
|
401
|
+
outcome: 'ok',
|
|
402
|
+
durationMs: attempt.durationMs,
|
|
403
|
+
...(tooSlow
|
|
404
|
+
? {
|
|
405
|
+
tooSlow: true,
|
|
406
|
+
detail: `exceeded preferred backend threshold ${preferredBackendMaxMs()}ms`,
|
|
407
|
+
}
|
|
408
|
+
: {}),
|
|
409
|
+
};
|
|
410
|
+
} else if (attempt.outcome === 'unavailable') {
|
|
411
|
+
results[attempt.backend] = { outcome: 'unavailable', detail: attempt.detail };
|
|
412
|
+
} else if (attempt.detail.startsWith('FORBIDDEN:')) {
|
|
413
|
+
results[attempt.backend] = {
|
|
414
|
+
outcome: 'forbidden',
|
|
415
|
+
durationMs: attempt.durationMs,
|
|
416
|
+
detail: attempt.detail.slice(0, 200),
|
|
417
|
+
};
|
|
418
|
+
} else {
|
|
419
|
+
const error = attempt.detail.split(':')[0] || 'UNKNOWN';
|
|
420
|
+
results[attempt.backend] = {
|
|
421
|
+
outcome: 'failed',
|
|
422
|
+
durationMs: attempt.durationMs,
|
|
423
|
+
error,
|
|
424
|
+
detail: attempt.detail.slice(0, 200),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
211
427
|
}
|
|
428
|
+
|
|
429
|
+
const existingPreferred = status.status === 'ok' ? status.cache.preferredOrder : [];
|
|
430
|
+
const observedOkAttempts = opts.attempts
|
|
431
|
+
.filter((a) => a.outcome === 'ok')
|
|
432
|
+
.sort((a, b) => a.durationMs - b.durationMs);
|
|
433
|
+
const observedOk = observedOkAttempts.map((a) => a.backend);
|
|
434
|
+
const slowObservedOk = observedOkAttempts
|
|
435
|
+
.filter((a) => a.durationMs > preferredBackendMaxMs())
|
|
436
|
+
.map((a) => a.backend);
|
|
437
|
+
const fastObservedOk = observedOk.filter((backend) => !slowObservedOk.includes(backend));
|
|
438
|
+
const usedOkAttempt = observedOkAttempts.find((a) => a.backend === opts.usedBackend);
|
|
439
|
+
const usedBackendTooSlow =
|
|
440
|
+
usedOkAttempt !== undefined && usedOkAttempt.durationMs > preferredBackendMaxMs();
|
|
441
|
+
const existingFast = existingPreferred.filter(
|
|
442
|
+
(backend) => !backendResultTooSlow(results[backend]),
|
|
443
|
+
);
|
|
444
|
+
const existingSlow = existingPreferred.filter((backend) =>
|
|
445
|
+
backendResultTooSlow(results[backend]),
|
|
446
|
+
);
|
|
447
|
+
const structuralFallbacks: ConcreteBackend[] = existsSync(
|
|
448
|
+
pathResolve(opts.tool.dir, 'playbook.yaml'),
|
|
449
|
+
)
|
|
450
|
+
? ['playbook']
|
|
451
|
+
: [];
|
|
452
|
+
const preferredOrder = uniqueBackends([
|
|
453
|
+
...(usedOkAttempt && !usedBackendTooSlow ? [opts.usedBackend] : []),
|
|
454
|
+
...existingFast,
|
|
455
|
+
...fastObservedOk,
|
|
456
|
+
...existingSlow,
|
|
457
|
+
...slowObservedOk,
|
|
458
|
+
...(usedOkAttempt && usedBackendTooSlow ? [opts.usedBackend] : []),
|
|
459
|
+
...structuralFallbacks,
|
|
460
|
+
]);
|
|
461
|
+
const cache: BackendsCache = {
|
|
462
|
+
probedAt: new Date().toISOString(),
|
|
463
|
+
imprintVersion: VERSION,
|
|
464
|
+
schemaVersion: 2,
|
|
465
|
+
workflowHash: workflowHash(opts.tool.workflow),
|
|
466
|
+
capabilityHash: capabilityHash(opts.tool.workflow),
|
|
467
|
+
preferredOrder,
|
|
468
|
+
results,
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
BackendsCacheSchema.parse(cache);
|
|
472
|
+
writeFileSync(pathResolve(opts.tool.dir, 'backends.json'), `${JSON.stringify(cache, null, 2)}\n`);
|
|
473
|
+
return cache;
|
|
212
474
|
}
|
|
213
475
|
|
|
214
476
|
function workflowHashSync(workflowJson: string): string {
|
|
@@ -217,6 +479,27 @@ function workflowHashSync(workflowJson: string): string {
|
|
|
217
479
|
.digest('hex');
|
|
218
480
|
}
|
|
219
481
|
|
|
482
|
+
function backendsCacheRemediation(site: string, toolName?: string): string {
|
|
483
|
+
return toolName
|
|
484
|
+
? `imprint probe-backends ${site} --tool ${toolName}`
|
|
485
|
+
: `imprint probe-backends ${site}`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function toolDirName(toolDir?: string): string | undefined {
|
|
489
|
+
return toolDir ? basename(toolDir) : undefined;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function uniqueBackends(backends: ConcreteBackend[]): ConcreteBackend[] {
|
|
493
|
+
const seen = new Set<ConcreteBackend>();
|
|
494
|
+
const out: ConcreteBackend[] = [];
|
|
495
|
+
for (const backend of backends) {
|
|
496
|
+
if (seen.has(backend)) continue;
|
|
497
|
+
seen.add(backend);
|
|
498
|
+
out.push(backend);
|
|
499
|
+
}
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
|
|
220
503
|
/** Param priority: caller overrides → cron.json → workflow defaults. */
|
|
221
504
|
function resolveParams(
|
|
222
505
|
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
|
+
}
|