imprint-mcp 0.6.1 → 0.6.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 -2
- package/prompts/audit-agent.md +4 -2
- package/prompts/compile-agent.md +46 -23
- package/prompts/live-verifier-agent.md +101 -0
- package/prompts/playbook-compilation.md +8 -1
- package/src/cli.ts +83 -1
- package/src/imprint/audit.ts +219 -20
- package/src/imprint/backend-cache.ts +18 -11
- package/src/imprint/backend-ladder.ts +48 -34
- package/src/imprint/cdp-browser-fetch.ts +135 -42
- package/src/imprint/claude-cli-compile.ts +38 -16
- package/src/imprint/codex-cli-compile.ts +120 -21
- package/src/imprint/compile-agent.ts +71 -26
- package/src/imprint/compile-tools.ts +169 -38
- package/src/imprint/compile-verification.ts +187 -0
- package/src/imprint/compile.ts +71 -2
- package/src/imprint/freeform-redact.ts +27 -6
- package/src/imprint/live-verifier.ts +1665 -0
- package/src/imprint/llm.ts +16 -0
- package/src/imprint/mcp-compile-server.ts +380 -81
- package/src/imprint/mcp-live-verifier-server.ts +359 -0
- package/src/imprint/mcp-server.ts +12 -2
- package/src/imprint/probe-backends.ts +88 -7
- package/src/imprint/runtime.ts +61 -11
- package/src/imprint/teach-state.ts +18 -1
- package/src/imprint/teach.ts +77 -19
- package/src/imprint/types.ts +39 -2
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join as pathJoin } from 'node:path';
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import {
|
|
6
|
+
CallToolRequestSchema,
|
|
7
|
+
type CallToolResult,
|
|
8
|
+
ListToolsRequestSchema,
|
|
9
|
+
type Tool,
|
|
10
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
+
import {
|
|
12
|
+
type LiveIntegrationEvidence,
|
|
13
|
+
runCapturedIntegrationCase,
|
|
14
|
+
} from './compile-verification.ts';
|
|
15
|
+
import {
|
|
16
|
+
LIVE_VERIFICATION_EVIDENCE_FILE,
|
|
17
|
+
LiveVerificationReportSchema,
|
|
18
|
+
appendLiveVerifierLog,
|
|
19
|
+
assertReportCoversWorkflowParameters,
|
|
20
|
+
effectiveParamsForEvidence,
|
|
21
|
+
hasSuiteReceiptForSession,
|
|
22
|
+
persistLiveVerificationEvidence,
|
|
23
|
+
prepareLiveVerificationBackend,
|
|
24
|
+
readPersistedLiveVerificationEvidence,
|
|
25
|
+
runLiveIntegrationSuite,
|
|
26
|
+
} from './live-verifier.ts';
|
|
27
|
+
import { loadCredentialStore } from './runtime.ts';
|
|
28
|
+
import { buildZodValidator } from './tool-loader.ts';
|
|
29
|
+
import { WorkflowSchema } from './types.ts';
|
|
30
|
+
|
|
31
|
+
export function joinBackendPreparation<T>(
|
|
32
|
+
state: { current?: { promise: Promise<T>; forceReprobe: boolean } },
|
|
33
|
+
forceReprobe: boolean,
|
|
34
|
+
start: () => Promise<T>,
|
|
35
|
+
): { promise: Promise<T>; joined: boolean } {
|
|
36
|
+
if (state.current && (!forceReprobe || state.current.forceReprobe)) {
|
|
37
|
+
return { promise: state.current.promise, joined: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A forced reprobe must not be satisfied by an ordinary preparation that
|
|
41
|
+
// was already in flight. Queue it behind that preparation so both calls do
|
|
42
|
+
// not race while updating the same backend cache. Further callers can join
|
|
43
|
+
// the queued forced preparation.
|
|
44
|
+
const preceding = state.current?.promise;
|
|
45
|
+
const started = preceding ? preceding.then(start, start) : start();
|
|
46
|
+
const promise = started.finally(() => {
|
|
47
|
+
if (state.current?.promise === promise) state.current = undefined;
|
|
48
|
+
});
|
|
49
|
+
state.current = { promise, forceReprobe };
|
|
50
|
+
return { promise, joined: false };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const RUN_TOOL: Tool = {
|
|
54
|
+
name: 'run_live_integration_test',
|
|
55
|
+
description:
|
|
56
|
+
'Run a targeted live call for a specific unresolved semantic question. Repeated parameters are allowed when the reason explains why.',
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
params: { type: 'object', additionalProperties: true },
|
|
61
|
+
reason: { type: 'string' },
|
|
62
|
+
},
|
|
63
|
+
required: ['params', 'reason'],
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const PREPARE_BACKEND_TOOL: Tool = {
|
|
68
|
+
name: 'prepare_live_backend',
|
|
69
|
+
description:
|
|
70
|
+
'Reuse the preferred backend and probe only when no valid preference exists. Set forceReprobe only after a transport, network, or browser-infrastructure failure from that backend; never reprobe for empty, incorrect, or otherwise semantic output failures.',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
params: { type: 'object', additionalProperties: true },
|
|
75
|
+
reason: { type: 'string' },
|
|
76
|
+
forceReprobe: { type: 'boolean' },
|
|
77
|
+
},
|
|
78
|
+
required: ['params', 'reason'],
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const RUN_SUITE_TOOL: Tool = {
|
|
83
|
+
name: 'run_live_integration_suite',
|
|
84
|
+
description:
|
|
85
|
+
'Run the compiler-proposed final integration suite. A justified rerun is allowed after failure, timeout, or backend reprobe.',
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: { reason: { type: 'string' } },
|
|
89
|
+
additionalProperties: false,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const SUBMIT_TOOL: Tool = {
|
|
94
|
+
name: 'submit_verification_report',
|
|
95
|
+
description: 'Submit the final structured semantic verification report exactly once.',
|
|
96
|
+
inputSchema: {
|
|
97
|
+
type: 'object',
|
|
98
|
+
properties: {
|
|
99
|
+
status: {
|
|
100
|
+
type: 'string',
|
|
101
|
+
enum: ['approved', 'approved_with_gaps', 'changes_required', 'inconclusive'],
|
|
102
|
+
},
|
|
103
|
+
summary: { type: 'string' },
|
|
104
|
+
baseline: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
verdict: {
|
|
108
|
+
type: 'string',
|
|
109
|
+
enum: ['semantically_correct', 'tool_broken', 'bad_parameters', 'infrastructure'],
|
|
110
|
+
},
|
|
111
|
+
reason: { type: 'string' },
|
|
112
|
+
},
|
|
113
|
+
required: ['verdict', 'reason'],
|
|
114
|
+
},
|
|
115
|
+
parameters: {
|
|
116
|
+
type: 'array',
|
|
117
|
+
items: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
name: { type: 'string' },
|
|
121
|
+
verdict: {
|
|
122
|
+
type: 'string',
|
|
123
|
+
enum: ['works', 'no_op', 'broken', 'untestable'],
|
|
124
|
+
},
|
|
125
|
+
reason: { type: 'string' },
|
|
126
|
+
},
|
|
127
|
+
required: ['name', 'verdict', 'reason'],
|
|
128
|
+
additionalProperties: false,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
issues: {
|
|
132
|
+
type: 'array',
|
|
133
|
+
items: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: {
|
|
136
|
+
summary: { type: 'string' },
|
|
137
|
+
expected: { type: 'string' },
|
|
138
|
+
observed: { type: 'string' },
|
|
139
|
+
suggestedFix: { type: 'string' },
|
|
140
|
+
},
|
|
141
|
+
required: ['summary', 'expected', 'observed', 'suggestedFix'],
|
|
142
|
+
additionalProperties: false,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
gaps: { type: 'array', items: { type: 'string' } },
|
|
146
|
+
},
|
|
147
|
+
required: ['status', 'summary', 'baseline', 'parameters', 'issues', 'gaps'],
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export async function runLiveVerifierMcpServer(opts: {
|
|
152
|
+
workflowPath: string;
|
|
153
|
+
reportPath: string;
|
|
154
|
+
sessionLabel: string;
|
|
155
|
+
logPath?: string;
|
|
156
|
+
attempt?: number;
|
|
157
|
+
}): Promise<void> {
|
|
158
|
+
const workflow = WorkflowSchema.parse(JSON.parse(readFileSync(opts.workflowPath, 'utf8')));
|
|
159
|
+
const validator = buildZodValidator(workflow.parameters);
|
|
160
|
+
const credentials = (await loadCredentialStore(workflow.site)) ?? undefined;
|
|
161
|
+
let submitted = false;
|
|
162
|
+
const backendPreparation: {
|
|
163
|
+
current?: {
|
|
164
|
+
promise: Promise<Awaited<ReturnType<typeof prepareLiveVerificationBackend>>>;
|
|
165
|
+
forceReprobe: boolean;
|
|
166
|
+
};
|
|
167
|
+
} = {};
|
|
168
|
+
const evidencePath = pathJoin(dirname(opts.reportPath), LIVE_VERIFICATION_EVIDENCE_FILE);
|
|
169
|
+
let targetedCallSequence = readPersistedLiveVerificationEvidence(evidencePath).reduce(
|
|
170
|
+
(highest, item) => {
|
|
171
|
+
const match = /^targeted-call-(\d+)$/.exec(item.label);
|
|
172
|
+
return match ? Math.max(highest, Number(match[1])) : highest;
|
|
173
|
+
},
|
|
174
|
+
0,
|
|
175
|
+
);
|
|
176
|
+
const hasSuiteReceipt = (): boolean => hasSuiteReceiptForSession(evidencePath, opts.sessionLabel);
|
|
177
|
+
|
|
178
|
+
const server = new Server(
|
|
179
|
+
{ name: 'imprint-live-verifier', version: '1.0.0' },
|
|
180
|
+
{ capabilities: { tools: {} } },
|
|
181
|
+
);
|
|
182
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
183
|
+
tools: [PREPARE_BACKEND_TOOL, RUN_SUITE_TOOL, RUN_TOOL, SUBMIT_TOOL],
|
|
184
|
+
}));
|
|
185
|
+
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
|
|
186
|
+
if (request.params.name === 'prepare_live_backend') {
|
|
187
|
+
const input = request.params.arguments as {
|
|
188
|
+
params?: unknown;
|
|
189
|
+
reason?: unknown;
|
|
190
|
+
forceReprobe?: unknown;
|
|
191
|
+
};
|
|
192
|
+
if (typeof input.reason !== 'string' || !input.reason.trim()) {
|
|
193
|
+
return errorResult('reason must explain the backend preparation or reprobe');
|
|
194
|
+
}
|
|
195
|
+
const parsed = validator.safeParse(input.params ?? {});
|
|
196
|
+
if (!parsed.success) return errorResult(`invalid tool params: ${parsed.error.message}`);
|
|
197
|
+
try {
|
|
198
|
+
const forceReprobe = input.forceReprobe === true;
|
|
199
|
+
const preparation = joinBackendPreparation(backendPreparation, forceReprobe, () =>
|
|
200
|
+
prepareLiveVerificationBackend({
|
|
201
|
+
workflowPath: opts.workflowPath,
|
|
202
|
+
params: parsed.data as Record<string, string | number | boolean>,
|
|
203
|
+
reason: input.reason as string,
|
|
204
|
+
forceReprobe,
|
|
205
|
+
logPath: opts.logPath,
|
|
206
|
+
attempt: opts.attempt,
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
if (preparation.joined) {
|
|
210
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
211
|
+
type: 'backend.prepare.joined',
|
|
212
|
+
attempt: opts.attempt,
|
|
213
|
+
reason: input.reason,
|
|
214
|
+
forceReprobe: input.forceReprobe === true,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const result = await preparation.promise;
|
|
218
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
219
|
+
} catch (error) {
|
|
220
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (request.params.name === 'run_live_integration_suite') {
|
|
224
|
+
const input = request.params.arguments as { reason?: unknown };
|
|
225
|
+
try {
|
|
226
|
+
const suite = await runLiveIntegrationSuite({
|
|
227
|
+
toolDir: dirname(opts.workflowPath),
|
|
228
|
+
logPath: opts.logPath,
|
|
229
|
+
attempt: opts.attempt,
|
|
230
|
+
reason: typeof input.reason === 'string' ? input.reason : undefined,
|
|
231
|
+
sessionLabel: opts.sessionLabel,
|
|
232
|
+
});
|
|
233
|
+
return { content: [{ type: 'text', text: JSON.stringify(suite) }] };
|
|
234
|
+
} catch (error) {
|
|
235
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (request.params.name === 'run_live_integration_test') {
|
|
239
|
+
if (!hasSuiteReceipt())
|
|
240
|
+
return errorResult('run the final integration suite before a targeted call');
|
|
241
|
+
const input = request.params.arguments as { params?: unknown; reason?: unknown };
|
|
242
|
+
if (typeof input.reason !== 'string' || !input.reason.trim()) {
|
|
243
|
+
return errorResult('reason must explain the unresolved semantic question');
|
|
244
|
+
}
|
|
245
|
+
const parsed = validator.safeParse(input.params ?? {});
|
|
246
|
+
if (!parsed.success) return errorResult(`invalid tool params: ${parsed.error.message}`);
|
|
247
|
+
const label = `targeted-call-${++targetedCallSequence}`;
|
|
248
|
+
const startedAt = Date.now();
|
|
249
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
250
|
+
type: 'targeted-call.started',
|
|
251
|
+
attempt: opts.attempt,
|
|
252
|
+
label,
|
|
253
|
+
reason: input.reason,
|
|
254
|
+
params: parsed.data,
|
|
255
|
+
});
|
|
256
|
+
let run: Awaited<ReturnType<typeof runCapturedIntegrationCase>>;
|
|
257
|
+
try {
|
|
258
|
+
run = await runCapturedIntegrationCase({
|
|
259
|
+
caseName: label,
|
|
260
|
+
workflowPath: opts.workflowPath,
|
|
261
|
+
params: parsed.data as Record<string, string | number | boolean>,
|
|
262
|
+
credentials,
|
|
263
|
+
preferredOnlyBackend: true,
|
|
264
|
+
});
|
|
265
|
+
} catch (error) {
|
|
266
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
267
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
268
|
+
type: 'targeted-call.failed',
|
|
269
|
+
attempt: opts.attempt,
|
|
270
|
+
label,
|
|
271
|
+
error: message,
|
|
272
|
+
});
|
|
273
|
+
return errorResult(message);
|
|
274
|
+
}
|
|
275
|
+
const supplemental: LiveIntegrationEvidence = {
|
|
276
|
+
schemaVersion: 1 as const,
|
|
277
|
+
kind: 'call',
|
|
278
|
+
label,
|
|
279
|
+
caseName: label,
|
|
280
|
+
toolName: workflow.toolName,
|
|
281
|
+
requestedParams: parsed.data as Record<string, string | number | boolean>,
|
|
282
|
+
effectiveParams: effectiveParamsForEvidence(
|
|
283
|
+
workflow.parameters,
|
|
284
|
+
parsed.data as Record<string, string | number | boolean>,
|
|
285
|
+
),
|
|
286
|
+
result: run.result,
|
|
287
|
+
usedBackend: run.usedBackend,
|
|
288
|
+
attempts: run.attempts,
|
|
289
|
+
durationMs: Date.now() - startedAt,
|
|
290
|
+
};
|
|
291
|
+
persistLiveVerificationEvidence(evidencePath, [supplemental]);
|
|
292
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
293
|
+
type: 'targeted-call.completed',
|
|
294
|
+
attempt: opts.attempt,
|
|
295
|
+
record: supplemental,
|
|
296
|
+
});
|
|
297
|
+
return {
|
|
298
|
+
content: [
|
|
299
|
+
{
|
|
300
|
+
type: 'text',
|
|
301
|
+
text: JSON.stringify({
|
|
302
|
+
label: supplemental.label,
|
|
303
|
+
toolName: workflow.toolName,
|
|
304
|
+
requestedParams: parsed.data,
|
|
305
|
+
result: run.result,
|
|
306
|
+
usedBackend: run.usedBackend,
|
|
307
|
+
attempts: run.attempts,
|
|
308
|
+
}),
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (request.params.name === 'submit_verification_report') {
|
|
314
|
+
if (submitted) return errorResult('verification report was already submitted');
|
|
315
|
+
const parsed = LiveVerificationReportSchema.safeParse(request.params.arguments);
|
|
316
|
+
if (!parsed.success)
|
|
317
|
+
return errorResult(`invalid verification report: ${parsed.error.message}`);
|
|
318
|
+
if (!hasSuiteReceipt()) {
|
|
319
|
+
return errorResult('run the final integration suite before submitting');
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
assertReportCoversWorkflowParameters(parsed.data, workflow.parameters);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
return errorResult(error instanceof Error ? error.message : String(error));
|
|
325
|
+
}
|
|
326
|
+
submitted = true;
|
|
327
|
+
appendLiveVerifierLog(opts.logPath, {
|
|
328
|
+
type: 'report.submitted',
|
|
329
|
+
attempt: opts.attempt,
|
|
330
|
+
report: parsed.data,
|
|
331
|
+
});
|
|
332
|
+
writeFileSync(opts.reportPath, `${JSON.stringify(parsed.data, null, 2)}\n`, {
|
|
333
|
+
encoding: 'utf8',
|
|
334
|
+
mode: 0o600,
|
|
335
|
+
});
|
|
336
|
+
return {
|
|
337
|
+
content: [{ type: 'text', text: 'Report accepted. End the verification session now.' }],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
return errorResult(`unknown tool: ${request.params.name}`);
|
|
341
|
+
});
|
|
342
|
+
const transport = new StdioServerTransport();
|
|
343
|
+
await server.connect(transport);
|
|
344
|
+
|
|
345
|
+
// Server.connect() resolves after initialization; it does not own the
|
|
346
|
+
// process lifetime. Keep stdio alive until the verifier disconnects, just
|
|
347
|
+
// like the compile MCP server does, or Codex can lose the server during its
|
|
348
|
+
// initialize handshake.
|
|
349
|
+
await new Promise<void>((resolve) => {
|
|
350
|
+
const close = (): void => resolve();
|
|
351
|
+
transport.onclose = close;
|
|
352
|
+
process.once('SIGINT', close);
|
|
353
|
+
process.once('SIGTERM', close);
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function errorResult(message: string): CallToolResult {
|
|
358
|
+
return { isError: true, content: [{ type: 'text', text: message }] };
|
|
359
|
+
}
|
|
@@ -61,10 +61,20 @@ interface ResolvedTool extends DiscoveredTool {
|
|
|
61
61
|
/** Tool description shown to MCP clients. Includes the operator's
|
|
62
62
|
* recorded narration when present — surprisingly load-bearing for the
|
|
63
63
|
* LLM picking the right tool. */
|
|
64
|
-
function buildToolDescription(w: ResolvedTool['workflow']): string {
|
|
64
|
+
export function buildToolDescription(w: ResolvedTool['workflow']): string {
|
|
65
65
|
const base = w.intent.description;
|
|
66
66
|
const said = w.intent.userSaid?.trim();
|
|
67
|
-
|
|
67
|
+
const context = said ? `${base}\n\nRecording context: "${said}"` : base;
|
|
68
|
+
if (!w.limitations?.length) return context;
|
|
69
|
+
const limitations = w.limitations
|
|
70
|
+
.map((entry) => {
|
|
71
|
+
const omitted = entry.omittedParameters?.length
|
|
72
|
+
? ` Omitted inputs: ${entry.omittedParameters.join(', ')}.`
|
|
73
|
+
: '';
|
|
74
|
+
return `- ${entry.feature}: ${entry.reason}${omitted}`;
|
|
75
|
+
})
|
|
76
|
+
.join('\n');
|
|
77
|
+
return `${context}\n\nKnown limitations:\n${limitations}`;
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
/** MCP advertises tool input as JSON Schema; build it directly from
|
|
@@ -107,7 +107,7 @@ export async function probeAllBackends(
|
|
|
107
107
|
return results;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
async function probeResolvedTool(
|
|
110
|
+
export async function probeResolvedTool(
|
|
111
111
|
opts: Pick<ProbeBackendsOptions, 'site' | 'paramOverrides'>,
|
|
112
112
|
assetRoot: string,
|
|
113
113
|
tool: ResolvedTool,
|
|
@@ -230,7 +230,7 @@ async function probeResolvedTool(
|
|
|
230
230
|
imprintVersion: VERSION,
|
|
231
231
|
schemaVersion: 2,
|
|
232
232
|
workflowHash: workflowHash(tool.workflow),
|
|
233
|
-
capabilityHash:
|
|
233
|
+
capabilityHash: workflowCapabilityHash(tool.workflow),
|
|
234
234
|
preferredOrder,
|
|
235
235
|
results,
|
|
236
236
|
};
|
|
@@ -323,16 +323,85 @@ function workflowHash(workflow: ResolvedTool['workflow']): string {
|
|
|
323
323
|
.digest('hex');
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
-
function
|
|
326
|
+
export function workflowCapabilityHash(workflow: ResolvedTool['workflow']): string {
|
|
327
327
|
const caps = {
|
|
328
328
|
bootstrap: Boolean(workflow.bootstrap),
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
329
|
+
prefersCdpReplayFirst: prefersCdpReplayFirst(workflow),
|
|
330
|
+
requestModes: normalizedUnique(workflow.requests.map((request) => request.mode ?? 'fetch')),
|
|
331
|
+
captures: normalizedUnique([
|
|
332
|
+
...(workflow.bootstrap?.captures ?? []).map(
|
|
333
|
+
(capture) => `bootstrap:${capture.source}:${capture.capability}:${capture.required}`,
|
|
334
|
+
),
|
|
335
|
+
...workflow.requests.flatMap((request) =>
|
|
336
|
+
(request.captures ?? []).map(
|
|
337
|
+
(capture) => `request:${capture.source}:${capture.capability}:${capture.required}`,
|
|
338
|
+
),
|
|
339
|
+
),
|
|
340
|
+
]),
|
|
332
341
|
};
|
|
333
342
|
return createHash('sha256').update(JSON.stringify(caps)).digest('hex');
|
|
334
343
|
}
|
|
335
344
|
|
|
345
|
+
function normalizedUnique(values: string[]): string[] {
|
|
346
|
+
return [...new Set(values)].sort();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function canRebindBackendsCacheToWorkflow(
|
|
350
|
+
tool: Pick<ResolvedTool, 'workflow'>,
|
|
351
|
+
cache: BackendsCache,
|
|
352
|
+
): boolean {
|
|
353
|
+
return (
|
|
354
|
+
typeof cache.capabilityHash === 'string' &&
|
|
355
|
+
cache.capabilityHash === workflowCapabilityHash(tool.workflow)
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Carry a previously proven backend preference across a compile-time workflow
|
|
360
|
+
* revision without performing network I/O. Runtime cache loading remains
|
|
361
|
+
* hash-strict; this adapter deliberately rebinds only after the verifier has
|
|
362
|
+
* decided the existing preference is still applicable. */
|
|
363
|
+
export function rebindBackendsCacheToWorkflow(
|
|
364
|
+
tool: Pick<ResolvedTool, 'workflow' | 'dir'>,
|
|
365
|
+
cache: BackendsCache,
|
|
366
|
+
outPath = pathResolve(tool.dir, 'backends.json'),
|
|
367
|
+
): BackendsCache {
|
|
368
|
+
if (!canRebindBackendsCacheToWorkflow(tool, cache)) {
|
|
369
|
+
throw new Error('backend cache capabilities changed; run a fresh probe instead of rebinding');
|
|
370
|
+
}
|
|
371
|
+
const rebound: BackendsCache = {
|
|
372
|
+
...cache,
|
|
373
|
+
workflowHash: workflowHash(tool.workflow),
|
|
374
|
+
capabilityHash: workflowCapabilityHash(tool.workflow),
|
|
375
|
+
};
|
|
376
|
+
BackendsCacheSchema.parse(rebound);
|
|
377
|
+
writeFileSync(outPath, `${JSON.stringify(rebound, null, 2)}\n`);
|
|
378
|
+
return rebound;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Rebind an already-proven cache after compile-time metadata is stamped onto
|
|
382
|
+
* workflow.json. This performs no network I/O and intentionally does nothing
|
|
383
|
+
* for missing/invalid caches. */
|
|
384
|
+
export function rebindExistingBackendsCacheToWorkflow(toolDir: string): BackendsCache | null {
|
|
385
|
+
const workflowPath = pathResolve(toolDir, 'workflow.json');
|
|
386
|
+
if (!existsSync(workflowPath)) return null;
|
|
387
|
+
|
|
388
|
+
let workflow: ResolvedTool['workflow'];
|
|
389
|
+
try {
|
|
390
|
+
workflow = WorkflowSchema.parse(JSON.parse(readFileSync(workflowPath, 'utf8')));
|
|
391
|
+
} catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const assetRoot = pathResolve(toolDir, '..', '..');
|
|
396
|
+
const status = loadBackendsCacheStatus(workflow.site, assetRoot, toolDir, {
|
|
397
|
+
warn: false,
|
|
398
|
+
toolName: workflow.toolName,
|
|
399
|
+
});
|
|
400
|
+
if (status.status !== 'ok' && status.status !== 'stale') return null;
|
|
401
|
+
if (!canRebindBackendsCacheToWorkflow({ workflow }, status.cache)) return null;
|
|
402
|
+
return rebindBackendsCacheToWorkflow({ workflow, dir: toolDir }, status.cache);
|
|
403
|
+
}
|
|
404
|
+
|
|
336
405
|
export function persistRuntimeBackendsCache(opts: {
|
|
337
406
|
tool: ResolvedTool;
|
|
338
407
|
assetRoot: string;
|
|
@@ -343,6 +412,18 @@ export function persistRuntimeBackendsCache(opts: {
|
|
|
343
412
|
warn: false,
|
|
344
413
|
toolName: opts.tool.workflow.toolName,
|
|
345
414
|
});
|
|
415
|
+
const soleAttempt = opts.attempts.length === 1 ? opts.attempts[0] : undefined;
|
|
416
|
+
if (
|
|
417
|
+
status.status === 'ok' &&
|
|
418
|
+
status.cache.preferredOrder[0] === opts.usedBackend &&
|
|
419
|
+
soleAttempt?.backend === opts.usedBackend &&
|
|
420
|
+
soleAttempt.outcome === 'ok'
|
|
421
|
+
) {
|
|
422
|
+
// A normal call through the already-proven preferred backend is not a
|
|
423
|
+
// probe or a new backend-learning event. Preserve the original probe time
|
|
424
|
+
// and its richer cold/warm/ranking diagnostics byte-for-byte on disk.
|
|
425
|
+
return status.cache;
|
|
426
|
+
}
|
|
346
427
|
const results: BackendsCache['results'] =
|
|
347
428
|
status.status === 'ok' ? { ...status.cache.results } : {};
|
|
348
429
|
|
|
@@ -408,7 +489,7 @@ export function persistRuntimeBackendsCache(opts: {
|
|
|
408
489
|
imprintVersion: VERSION,
|
|
409
490
|
schemaVersion: 2,
|
|
410
491
|
workflowHash: workflowHash(opts.tool.workflow),
|
|
411
|
-
capabilityHash:
|
|
492
|
+
capabilityHash: workflowCapabilityHash(opts.tool.workflow),
|
|
412
493
|
preferredOrder,
|
|
413
494
|
results,
|
|
414
495
|
};
|
package/src/imprint/runtime.ts
CHANGED
|
@@ -41,6 +41,44 @@ export interface CredentialStore {
|
|
|
41
41
|
storage?: StorageRecord[];
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
type NavigationOptions = NonNullable<WorkflowRequest['navigation']>;
|
|
45
|
+
type RequestTransformResult =
|
|
46
|
+
| string
|
|
47
|
+
| {
|
|
48
|
+
url?: string;
|
|
49
|
+
body?: string;
|
|
50
|
+
headers?: Record<string, string>;
|
|
51
|
+
navigation?: Partial<NavigationOptions>;
|
|
52
|
+
skip?: boolean;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function mergeNavigationOptions(
|
|
56
|
+
base: WorkflowRequest['navigation'],
|
|
57
|
+
override: Partial<NavigationOptions>,
|
|
58
|
+
): NavigationOptions {
|
|
59
|
+
if (
|
|
60
|
+
override.actions !== undefined &&
|
|
61
|
+
(!Array.isArray(override.actions) ||
|
|
62
|
+
override.actions.length === 0 ||
|
|
63
|
+
override.actions.some(
|
|
64
|
+
(entry) =>
|
|
65
|
+
!entry ||
|
|
66
|
+
entry.action !== 'click' ||
|
|
67
|
+
typeof entry.selector !== 'string' ||
|
|
68
|
+
entry.selector.length === 0,
|
|
69
|
+
))
|
|
70
|
+
) {
|
|
71
|
+
throw new Error('request transform returned invalid navigation click actions');
|
|
72
|
+
}
|
|
73
|
+
if (
|
|
74
|
+
override.resultSelector !== undefined &&
|
|
75
|
+
(typeof override.resultSelector !== 'string' || override.resultSelector.length === 0)
|
|
76
|
+
) {
|
|
77
|
+
throw new Error('request transform returned an invalid navigation result selector');
|
|
78
|
+
}
|
|
79
|
+
return { ...base, ...override } as NavigationOptions;
|
|
80
|
+
}
|
|
81
|
+
|
|
44
82
|
/** Browser-only workflow operations supplied by cdp-replay. Keeping this
|
|
45
83
|
* transport generic lets pages mint coupled browser state through their own
|
|
46
84
|
* JavaScript without adding OAuth-provider or frontend-framework logic here. */
|
|
@@ -55,6 +93,9 @@ export interface BrowserNavigationTransport {
|
|
|
55
93
|
timeoutMs?: number;
|
|
56
94
|
pollIntervalMs?: number;
|
|
57
95
|
urlIncludes?: string;
|
|
96
|
+
selector?: string;
|
|
97
|
+
actions?: Array<{ action: 'click'; selector: string }>;
|
|
98
|
+
resultSelector?: string;
|
|
58
99
|
cookie?: { name: string; domain?: string; path?: string };
|
|
59
100
|
},
|
|
60
101
|
): Promise<Response>;
|
|
@@ -178,16 +219,13 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
178
219
|
const dependencyPreflight = preflightStateDependencies(opts.workflow, state, stateCapabilities);
|
|
179
220
|
if (!dependencyPreflight.ok) return dependencyPreflight.result;
|
|
180
221
|
|
|
181
|
-
type TransformResult =
|
|
182
|
-
| string
|
|
183
|
-
| { url?: string; body?: string; headers?: Record<string, string>; skip?: boolean };
|
|
184
222
|
let requestTransform:
|
|
185
223
|
| ((
|
|
186
224
|
method: string,
|
|
187
225
|
url: string,
|
|
188
226
|
responses: unknown[],
|
|
189
227
|
params?: Record<string, string | number | boolean>,
|
|
190
|
-
) =>
|
|
228
|
+
) => RequestTransformResult)
|
|
191
229
|
| null = null;
|
|
192
230
|
if (opts.workflow.requestTransformModule && opts.workflowPath) {
|
|
193
231
|
try {
|
|
@@ -217,6 +255,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
217
255
|
});
|
|
218
256
|
if (!subbedResult.ok) return subbedResult.result;
|
|
219
257
|
const subbed = subbedResult.value;
|
|
258
|
+
let navigation = req.navigation;
|
|
220
259
|
|
|
221
260
|
if (requestTransform) {
|
|
222
261
|
try {
|
|
@@ -237,9 +276,18 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
237
276
|
subbed.headers[k] = v;
|
|
238
277
|
}
|
|
239
278
|
}
|
|
279
|
+
if (transformResult.navigation) {
|
|
280
|
+
navigation = mergeNavigationOptions(navigation, transformResult.navigation);
|
|
281
|
+
}
|
|
240
282
|
}
|
|
241
|
-
} catch {
|
|
242
|
-
|
|
283
|
+
} catch (err) {
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
error: 'BAD_RESPONSE',
|
|
287
|
+
message: `request transform failed for request ${i}: ${
|
|
288
|
+
err instanceof Error ? err.message : String(err)
|
|
289
|
+
}`,
|
|
290
|
+
};
|
|
243
291
|
}
|
|
244
292
|
}
|
|
245
293
|
|
|
@@ -258,7 +306,7 @@ export async function executeWorkflow<T = unknown>(opts: ExecuteOptions): Promis
|
|
|
258
306
|
}
|
|
259
307
|
try {
|
|
260
308
|
resp = await opts.browser.navigate(subbed.url, {
|
|
261
|
-
...
|
|
309
|
+
...navigation,
|
|
262
310
|
method: subbed.method,
|
|
263
311
|
headers: subbed.headers,
|
|
264
312
|
body: subbed.body,
|
|
@@ -469,9 +517,7 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
469
517
|
url: string,
|
|
470
518
|
responses: unknown[],
|
|
471
519
|
params?: Record<string, string | number | boolean>,
|
|
472
|
-
) =>
|
|
473
|
-
| string
|
|
474
|
-
| { url?: string; body?: string; headers?: Record<string, string>; skip?: boolean })
|
|
520
|
+
) => RequestTransformResult)
|
|
475
521
|
| undefined;
|
|
476
522
|
if (opts.workflow.requestTransformModule && opts.workflowPath) {
|
|
477
523
|
try {
|
|
@@ -543,6 +589,7 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
543
589
|
});
|
|
544
590
|
if (!substituted.ok) return substituted;
|
|
545
591
|
const request = substituted.value;
|
|
592
|
+
let navigation = req.navigation;
|
|
546
593
|
|
|
547
594
|
if (requestTransform) {
|
|
548
595
|
try {
|
|
@@ -558,6 +605,9 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
558
605
|
if (transformed.url !== undefined) request.url = transformed.url;
|
|
559
606
|
if (transformed.body !== undefined) request.body = transformed.body;
|
|
560
607
|
if (transformed.headers) Object.assign(request.headers, transformed.headers);
|
|
608
|
+
if (transformed.navigation) {
|
|
609
|
+
navigation = mergeNavigationOptions(navigation, transformed.navigation);
|
|
610
|
+
}
|
|
561
611
|
}
|
|
562
612
|
} catch (err) {
|
|
563
613
|
return {
|
|
@@ -592,7 +642,7 @@ async function executeAuthWorkflow(opts: ExecuteOptions): Promise<ToolResult> {
|
|
|
592
642
|
}
|
|
593
643
|
try {
|
|
594
644
|
response = await opts.browser.navigate(request.url, {
|
|
595
|
-
...
|
|
645
|
+
...navigation,
|
|
596
646
|
method: request.method,
|
|
597
647
|
headers: request.headers,
|
|
598
648
|
body: request.body,
|