imprint-mcp 0.4.0 → 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 +43 -16
- package/src/imprint/cdp-browser-fetch.ts +277 -170
- 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
|
@@ -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
|
+
}
|