@llm4ts/shell 2.15.0 → 2.17.0
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/dist/Cli.d.ts +1 -1
- package/dist/Cli.d.ts.map +1 -1
- package/flows/epic-design.js +93 -0
- package/flows/fixtures/epic-design/legacy/docs/modernization/README.md +6 -0
- package/flows/fixtures/epic-design/legacy/docs/modernization/features/conto_movimenti.feature +16 -0
- package/flows/fixtures/epic-design/legacy/docs/modernization/features/conto_saldo.feature +10 -0
- package/flows/fixtures/epic-design/legacy/docs/modernization/specs/CONTO_MOVIMENTI.md +8 -0
- package/flows/fixtures/epic-design/legacy/docs/modernization/specs/CONTO_SALDO.md +8 -0
- package/flows/fixtures/epic-design/target/CONTRIBUTING.md +5 -0
- package/flows/fixtures/epic-design/target/src/kit/session/SessionGuard.tsx +2 -0
- package/flows/lib/epic-design.js +341 -0
- package/flows/lib/epic-stories.js +92 -4
- package/kits/soap-ace/README.md +50 -9
- package/kits/soap-ace/fixtures/demo-bank-soap/RUNBOOK.md +16 -0
- package/kits/soap-ace/flows/lib/soap/Call.js +10 -6
- package/kits/soap-ace/flows/lib/soap/Explore.js +476 -0
- package/kits/soap-ace/flows/soap-explore.js +190 -0
- package/package.json +4 -4
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import { schemaPaths, writeAnalyses } from "./Analysis.js";
|
|
3
|
+
import { callOperation, RequestInvalid } from "./Call.js";
|
|
4
|
+
import { elementByName } from "./Catalog.js";
|
|
5
|
+
import { heuristicClass } from "./Classification.js";
|
|
6
|
+
import { blockingIssues, checkDesign, designPaths, parseDesignFile, renderDesignFile } from "./Design.js";
|
|
7
|
+
import { proposeDesign, reviseDesign } from "./DesignProposal.js";
|
|
8
|
+
import { readIfPresent, serviceDirectory, servicePaths } from "./Discover.js";
|
|
9
|
+
import { instanceFromXml } from "./Instance.js";
|
|
10
|
+
import { encodeMapping, mapService, renderMapping } from "./Mapping.js";
|
|
11
|
+
import { collectSeeds, readExchanges, renderRequestFile, samplePaths, seedsForService } from "./Samples.js";
|
|
12
|
+
import { isYamlMap, parseYaml } from "./Yaml.js";
|
|
13
|
+
// soap-explore: the whole discovery half in one pass, stopping only where a
|
|
14
|
+
// person decides. Given a catalog it writes a request for every operation,
|
|
15
|
+
// calls the read operations once in producer-first order (values from one
|
|
16
|
+
// response seed the next request), and reports every gap with the command
|
|
17
|
+
// that closes it. Each step keeps what exists, so a rerun continues.
|
|
18
|
+
//
|
|
19
|
+
// Chained values are live only in memory: the call sends the value the
|
|
20
|
+
// service answered with, the request file on disk carries its masked form.
|
|
21
|
+
export const exploreRequestName = "happy-path";
|
|
22
|
+
/** First line of a request file soap-explore owns; deleting it hands the file to the user. */
|
|
23
|
+
export const exploreMarker = "# soap-explore: generated. Delete this line to keep your edits; soap-explore then leaves the file alone.";
|
|
24
|
+
export const explorePaths = (service) => ({
|
|
25
|
+
report: `${serviceDirectory(service)}/explore.md`
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* Which operations the probe may call. Only reads, ever. A confirmed
|
|
29
|
+
* operations.md decides alone; before confirmation, a provisional probe needs
|
|
30
|
+
* dev or test, the file still saying read, the name heuristic saying read,
|
|
31
|
+
* and the judgment saying read with an `act` decision. `judged` undefined
|
|
32
|
+
* means no judgment seat is configured.
|
|
33
|
+
*/
|
|
34
|
+
export const probeDecisions = (options) => options.catalog.operations.map(({ name }) => {
|
|
35
|
+
const skip = (reason) => ({ operation: name, probe: false, reason });
|
|
36
|
+
if (options.environment === undefined)
|
|
37
|
+
return skip("no auth.json: nothing is called");
|
|
38
|
+
const file = options.operations;
|
|
39
|
+
const listed = file?.classes.get(name) ?? "unclassified";
|
|
40
|
+
if (file?.confirmed === true) {
|
|
41
|
+
if (listed === "read") {
|
|
42
|
+
return { operation: name, probe: true, basis: "confirmed", reason: "confirmed read" };
|
|
43
|
+
}
|
|
44
|
+
return skip(listed === "mutating"
|
|
45
|
+
? "mutating: never probed"
|
|
46
|
+
: "unclassified in operations.md: not callable");
|
|
47
|
+
}
|
|
48
|
+
if (options.environment === "uat")
|
|
49
|
+
return skip("uat: confirm operations.md before any call");
|
|
50
|
+
if (listed !== "read") {
|
|
51
|
+
return skip(listed === "mutating" ? "mutating: never probed" : "unclassified: confirm operations.md");
|
|
52
|
+
}
|
|
53
|
+
const heuristic = heuristicClass(name);
|
|
54
|
+
if (heuristic.class !== "read") {
|
|
55
|
+
return skip(`heuristic says ${heuristic.class}: confirm operations.md to call it`);
|
|
56
|
+
}
|
|
57
|
+
if (options.judged === undefined) {
|
|
58
|
+
return skip("no judgment seat (LLM4TS_JUDGMENT_PROVIDER): confirm operations.md to call it");
|
|
59
|
+
}
|
|
60
|
+
const judged = options.judged.get(name);
|
|
61
|
+
if (judged === undefined)
|
|
62
|
+
return skip("the judgment did not answer: confirm operations.md");
|
|
63
|
+
if (judged.class !== "read" || judged.decision !== "act") {
|
|
64
|
+
return skip(`judgment says ${judged.class} ${judged.confidence.toFixed(2)} (${judged.decision}): confirm operations.md`);
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
operation: name,
|
|
68
|
+
probe: true,
|
|
69
|
+
basis: "provisional",
|
|
70
|
+
reason: `provisional read: heuristic and judgment agree (${judged.confidence.toFixed(2)})`
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Producer-first order
|
|
75
|
+
const fieldName = (path) => (path.split(".").pop() ?? path).replace(/\[\]$/, "").replace(/^@/, "");
|
|
76
|
+
/** Leaf field names of an element; `requiredOnly` drops optional ones and those under optional parents. */
|
|
77
|
+
export const leafNames = (catalog, element, requiredOnly) => {
|
|
78
|
+
const declared = element === undefined ? undefined : elementByName(catalog, element);
|
|
79
|
+
if (declared === undefined)
|
|
80
|
+
return new Set();
|
|
81
|
+
const paths = schemaPaths(catalog, declared);
|
|
82
|
+
const optional = paths.filter((path) => path.optional).map((path) => path.path);
|
|
83
|
+
return new Set(paths
|
|
84
|
+
.filter((path) => path.leaf &&
|
|
85
|
+
(!requiredOnly ||
|
|
86
|
+
(!path.optional &&
|
|
87
|
+
!optional.some((prefix) => path.path.startsWith(`${prefix}.`) || path.path.startsWith(`${prefix}@`)))))
|
|
88
|
+
.map((path) => fieldName(path.path)));
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Order `names` so producers run first: A precedes B when a leaf of A's
|
|
92
|
+
* response is a required input field of B. Kahn's algorithm; ties and cycles
|
|
93
|
+
* resolve in catalog order.
|
|
94
|
+
*/
|
|
95
|
+
export const probeOrder = (catalog, names) => {
|
|
96
|
+
const operations = catalog.operations.filter((operation) => names.includes(operation.name));
|
|
97
|
+
const producers = new Map();
|
|
98
|
+
for (const consumer of operations) {
|
|
99
|
+
const needs = leafNames(catalog, consumer.input, true);
|
|
100
|
+
const links = [];
|
|
101
|
+
for (const producer of operations) {
|
|
102
|
+
if (producer === consumer)
|
|
103
|
+
continue;
|
|
104
|
+
const gives = leafNames(catalog, producer.output, false);
|
|
105
|
+
const fields = [...needs].filter((field) => gives.has(field));
|
|
106
|
+
if (fields.length > 0)
|
|
107
|
+
links.push({ producer: producer.name, fields });
|
|
108
|
+
}
|
|
109
|
+
producers.set(consumer.name, links);
|
|
110
|
+
}
|
|
111
|
+
const order = [];
|
|
112
|
+
const remaining = operations.map((operation) => operation.name);
|
|
113
|
+
while (remaining.length > 0) {
|
|
114
|
+
const ready = remaining.find((name) => (producers.get(name) ?? []).every((link) => !remaining.includes(link.producer))) ?? remaining[0];
|
|
115
|
+
if (ready === undefined)
|
|
116
|
+
break;
|
|
117
|
+
order.push(ready);
|
|
118
|
+
remaining.splice(remaining.indexOf(ready), 1);
|
|
119
|
+
}
|
|
120
|
+
return { order, producers };
|
|
121
|
+
};
|
|
122
|
+
/** First value per field name in a response payload. */
|
|
123
|
+
export const payloadSeeds = (catalog, operation, payload) => {
|
|
124
|
+
const output = operation.output === undefined ? undefined : elementByName(catalog, operation.output);
|
|
125
|
+
if (output === undefined)
|
|
126
|
+
return new Map();
|
|
127
|
+
const seeds = new Map();
|
|
128
|
+
for (const [key, value] of collectSeeds([instanceFromXml(catalog, output, payload).value])) {
|
|
129
|
+
if (key === "#text")
|
|
130
|
+
continue;
|
|
131
|
+
const name = key.replace(/^@/, "");
|
|
132
|
+
if (!seeds.has(name))
|
|
133
|
+
seeds.set(name, value);
|
|
134
|
+
}
|
|
135
|
+
return seeds;
|
|
136
|
+
};
|
|
137
|
+
const describeExchange = (exchange) => exchange.fault !== undefined
|
|
138
|
+
? `fault ${exchange.fault.code} ${exchange.fault.reason}`
|
|
139
|
+
: `HTTP ${exchange.response?.status ?? "?"}`;
|
|
140
|
+
const withSeeds = (base, chained, fields) => {
|
|
141
|
+
const seeds = new Map(base);
|
|
142
|
+
for (const field of fields) {
|
|
143
|
+
const value = chained.get(field);
|
|
144
|
+
if (value !== undefined)
|
|
145
|
+
seeds.set(field, value);
|
|
146
|
+
}
|
|
147
|
+
return seeds;
|
|
148
|
+
};
|
|
149
|
+
export const exploreService = (options) => Effect.gen(function* () {
|
|
150
|
+
const { workspace, catalog, service } = options;
|
|
151
|
+
const progress = options.progress ?? (() => Effect.void);
|
|
152
|
+
const decisions = probeDecisions({
|
|
153
|
+
catalog,
|
|
154
|
+
operations: options.operations,
|
|
155
|
+
environment: options.profile?.environment,
|
|
156
|
+
judged: options.judged
|
|
157
|
+
});
|
|
158
|
+
const probed = decisions.filter((decision) => decision.probe).map((d) => d.operation);
|
|
159
|
+
const plan = probeOrder(catalog, probed);
|
|
160
|
+
// The probe calls only operations it decided on, as confirmed reads.
|
|
161
|
+
const probeFile = {
|
|
162
|
+
confirmed: true,
|
|
163
|
+
classes: new Map(probed.map((name) => [name, "read"]))
|
|
164
|
+
};
|
|
165
|
+
const serviceSeeds = yield* seedsForService(workspace, catalog, service);
|
|
166
|
+
const live = new Map();
|
|
167
|
+
const masked = new Map();
|
|
168
|
+
// Which operation's response supplied each chained field.
|
|
169
|
+
const suppliedBy = new Map();
|
|
170
|
+
const reports = new Map();
|
|
171
|
+
/**
|
|
172
|
+
* Write the request when absent; regenerate one explore owns only before
|
|
173
|
+
* calling it. A file the user owns (no marker line, or the XML form) is
|
|
174
|
+
* never touched.
|
|
175
|
+
*/
|
|
176
|
+
const ensureRequest = (operation, chainedFields, notes, regenerate) => Effect.gen(function* () {
|
|
177
|
+
const paths = samplePaths(service, operation.name, exploreRequestName);
|
|
178
|
+
const yaml = yield* readIfPresent(workspace, paths.yaml);
|
|
179
|
+
const xml = yield* readIfPresent(workspace, paths.xml);
|
|
180
|
+
if (xml !== undefined ||
|
|
181
|
+
(yaml !== undefined && (!regenerate || !yaml.startsWith(exploreMarker)))) {
|
|
182
|
+
return "kept";
|
|
183
|
+
}
|
|
184
|
+
const text = renderRequestFile({
|
|
185
|
+
catalog,
|
|
186
|
+
operation: operation.name,
|
|
187
|
+
purpose: "happy path",
|
|
188
|
+
seeds: withSeeds(serviceSeeds, masked, chainedFields)
|
|
189
|
+
});
|
|
190
|
+
yield* workspace.write(paths.yaml, [exploreMarker, ...notes, text].join("\n"));
|
|
191
|
+
return yaml === undefined ? "written" : "regenerated";
|
|
192
|
+
});
|
|
193
|
+
for (const name of plan.order) {
|
|
194
|
+
const operation = catalog.operations.find((candidate) => candidate.name === name);
|
|
195
|
+
const decision = decisions.find((candidate) => candidate.operation === name);
|
|
196
|
+
if (operation === undefined || decision === undefined)
|
|
197
|
+
continue;
|
|
198
|
+
const listed = options.operations?.classes.get(name) ?? "unclassified";
|
|
199
|
+
const paths = samplePaths(service, name, exploreRequestName);
|
|
200
|
+
const recorded = yield* readIfPresent(workspace, paths.exchange);
|
|
201
|
+
const links = (plan.producers.get(name) ?? [])
|
|
202
|
+
.map((link) => ({
|
|
203
|
+
...link,
|
|
204
|
+
fields: link.fields.filter((field) => suppliedBy.get(field) === link.producer)
|
|
205
|
+
}))
|
|
206
|
+
.filter((link) => link.fields.length > 0);
|
|
207
|
+
const chainedFields = new Set(links.flatMap((link) => link.fields));
|
|
208
|
+
if (recorded !== undefined && !options.refresh) {
|
|
209
|
+
const request = yield* ensureRequest(operation, new Set(), [], false);
|
|
210
|
+
reports.set(name, {
|
|
211
|
+
operation: name,
|
|
212
|
+
listed,
|
|
213
|
+
decision,
|
|
214
|
+
request,
|
|
215
|
+
outcome: { kind: "recorded", detail: "recorded earlier; --refresh calls it again" },
|
|
216
|
+
chained: []
|
|
217
|
+
});
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const notes = links.length === 0
|
|
221
|
+
? []
|
|
222
|
+
: [
|
|
223
|
+
`# Chained at probe time: ${links.map((link) => `${link.fields.join(", ")} from ${link.producer}`).join("; ")}.`,
|
|
224
|
+
"# Personal values appear masked here; soap-explore sent the live ones and never stores them."
|
|
225
|
+
];
|
|
226
|
+
const request = yield* ensureRequest(operation, chainedFields, notes, true);
|
|
227
|
+
// A file explore owns is sent with the live chained values; the file on
|
|
228
|
+
// disk keeps the masked ones. A user's file is sent as written.
|
|
229
|
+
const override = request === "kept" || chainedFields.size === 0
|
|
230
|
+
? undefined
|
|
231
|
+
: yield* parseYaml(renderRequestFile({
|
|
232
|
+
catalog,
|
|
233
|
+
operation: name,
|
|
234
|
+
purpose: "happy path",
|
|
235
|
+
seeds: withSeeds(serviceSeeds, live, chainedFields)
|
|
236
|
+
})).pipe(Effect.option);
|
|
237
|
+
const body = override !== undefined && override._tag === "Some" && isYamlMap(override.value)
|
|
238
|
+
? override.value["body"]
|
|
239
|
+
: undefined;
|
|
240
|
+
const outcome = yield* callOperation({
|
|
241
|
+
workspace,
|
|
242
|
+
catalog,
|
|
243
|
+
service,
|
|
244
|
+
operation: name,
|
|
245
|
+
name: exploreRequestName,
|
|
246
|
+
profile: options.profile,
|
|
247
|
+
operations: probeFile,
|
|
248
|
+
secrets: options.secrets,
|
|
249
|
+
transport: options.transport,
|
|
250
|
+
allowMutating: undefined,
|
|
251
|
+
confirm: () => Effect.succeed(false),
|
|
252
|
+
...(body === undefined
|
|
253
|
+
? {}
|
|
254
|
+
: { request: { purpose: "happy path", body, form: "yaml", readIssues: [] } }),
|
|
255
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
256
|
+
...(options.random === undefined ? {} : { random: options.random })
|
|
257
|
+
}).pipe(Effect.result);
|
|
258
|
+
let result;
|
|
259
|
+
if (outcome._tag === "Failure") {
|
|
260
|
+
// A request that did not validate may carry live chained values in
|
|
261
|
+
// its issue text: report where, never what.
|
|
262
|
+
const error = outcome.failure;
|
|
263
|
+
result = {
|
|
264
|
+
kind: "failed",
|
|
265
|
+
detail: error instanceof RequestInvalid
|
|
266
|
+
? `not sent, the request does not validate at ${error.issues.map((issue) => issue.path).join(", ")}`
|
|
267
|
+
: error.message
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
const { exchange, payload } = outcome.success;
|
|
272
|
+
result = {
|
|
273
|
+
kind: "called",
|
|
274
|
+
detail: `${describeExchange(exchange)}, ${exchange.response?.elapsedMs ?? 0} ms`,
|
|
275
|
+
findings: exchange.responseIssues.length
|
|
276
|
+
};
|
|
277
|
+
if (payload !== undefined) {
|
|
278
|
+
for (const [field, value] of payloadSeeds(catalog, operation, payload.live)) {
|
|
279
|
+
if (!live.has(field)) {
|
|
280
|
+
live.set(field, value);
|
|
281
|
+
suppliedBy.set(field, name);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (const [field, value] of payloadSeeds(catalog, operation, payload.masked)) {
|
|
285
|
+
if (!masked.has(field))
|
|
286
|
+
masked.set(field, value);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
yield* progress(`${name}: ${result.detail}${links.length === 0 ? "" : ` (chained ${[...chainedFields].join(", ")})`}`);
|
|
291
|
+
reports.set(name, {
|
|
292
|
+
operation: name,
|
|
293
|
+
listed,
|
|
294
|
+
decision,
|
|
295
|
+
request,
|
|
296
|
+
outcome: result,
|
|
297
|
+
chained: links
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
// Every other operation gets a request to edit, seeded with what the
|
|
301
|
+
// probe learnt (masked), and the reason it was not called.
|
|
302
|
+
for (const operation of catalog.operations) {
|
|
303
|
+
if (reports.has(operation.name))
|
|
304
|
+
continue;
|
|
305
|
+
const decision = decisions.find((candidate) => candidate.operation === operation.name);
|
|
306
|
+
if (decision === undefined)
|
|
307
|
+
continue;
|
|
308
|
+
const needs = leafNames(catalog, operation.input, true);
|
|
309
|
+
const request = yield* ensureRequest(operation, new Set([...needs].filter((field) => masked.has(field))), [], false);
|
|
310
|
+
const recorded = yield* readIfPresent(workspace, samplePaths(service, operation.name, exploreRequestName).exchange);
|
|
311
|
+
reports.set(operation.name, {
|
|
312
|
+
operation: operation.name,
|
|
313
|
+
listed: options.operations?.classes.get(operation.name) ?? "unclassified",
|
|
314
|
+
decision,
|
|
315
|
+
request,
|
|
316
|
+
outcome: recorded === undefined
|
|
317
|
+
? { kind: "not-called", detail: decision.reason }
|
|
318
|
+
: { kind: "recorded", detail: "recorded earlier" },
|
|
319
|
+
chained: []
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
decisions,
|
|
324
|
+
plan,
|
|
325
|
+
operations: catalog.operations.flatMap((operation) => {
|
|
326
|
+
const report = reports.get(operation.name);
|
|
327
|
+
return report === undefined ? [] : [report];
|
|
328
|
+
})
|
|
329
|
+
};
|
|
330
|
+
});
|
|
331
|
+
/** analysis/<op>.md for every operation, then design/mapping.json + .md. */
|
|
332
|
+
export const writeEvidence = (workspace, catalog, service) => Effect.gen(function* () {
|
|
333
|
+
const analyses = yield* writeAnalyses(workspace, catalog, service);
|
|
334
|
+
const mapping = mapService(catalog);
|
|
335
|
+
const paths = designPaths(serviceDirectory(service));
|
|
336
|
+
yield* workspace.write(paths.mapping, encodeMapping(mapping));
|
|
337
|
+
yield* workspace.write(paths.mappingSummary, renderMapping(mapping));
|
|
338
|
+
const exchanges = (yield* readExchanges(workspace, service)).length;
|
|
339
|
+
return { analyses, exchanges };
|
|
340
|
+
});
|
|
341
|
+
/**
|
|
342
|
+
* The design step: an existing api-design.md is only checked; otherwise,
|
|
343
|
+
* with evidence and a reasoning seat, draft it, check it, and revise once
|
|
344
|
+
* when errors remain. The file is always written as proposed.
|
|
345
|
+
*/
|
|
346
|
+
export const designStep = (options) => Effect.gen(function* () {
|
|
347
|
+
const { workspace, catalog, operations } = options;
|
|
348
|
+
const analyses = options.evidence.analyses;
|
|
349
|
+
const paths = designPaths(serviceDirectory(options.service));
|
|
350
|
+
const existing = yield* readIfPresent(workspace, paths.design);
|
|
351
|
+
if (existing !== undefined) {
|
|
352
|
+
const file = yield* parseDesignFile(existing).pipe(Effect.result);
|
|
353
|
+
if (file._tag === "Failure")
|
|
354
|
+
return { kind: "failed", detail: file.failure.message };
|
|
355
|
+
const issues = checkDesign(file.success.design, { catalog, operations, analyses });
|
|
356
|
+
yield* workspace.write(paths.design, renderDesignFile(file.success.design, issues, file.success.approved ? "approved" : "proposed"));
|
|
357
|
+
const errors = blockingIssues(issues).length;
|
|
358
|
+
return {
|
|
359
|
+
kind: "checked",
|
|
360
|
+
approved: file.success.approved,
|
|
361
|
+
errors,
|
|
362
|
+
warnings: issues.length - errors
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if ("off" in options.reasoning)
|
|
366
|
+
return { kind: "skipped", reason: options.reasoning.off };
|
|
367
|
+
if (options.evidence.exchanges === 0) {
|
|
368
|
+
return { kind: "skipped", reason: "no exchanges yet: the design needs evidence" };
|
|
369
|
+
}
|
|
370
|
+
const reasoning = options.reasoning;
|
|
371
|
+
const base = {
|
|
372
|
+
catalog,
|
|
373
|
+
operations,
|
|
374
|
+
mapping: mapService(catalog),
|
|
375
|
+
analyses,
|
|
376
|
+
style: options.style
|
|
377
|
+
};
|
|
378
|
+
const drafted = yield* proposeDesign(reasoning, base).pipe(Effect.result);
|
|
379
|
+
if (drafted._tag === "Failure") {
|
|
380
|
+
return { kind: "failed", detail: `drafting failed: ${drafted.failure.message}` };
|
|
381
|
+
}
|
|
382
|
+
let design = drafted.success;
|
|
383
|
+
let issues = checkDesign(design, { catalog, operations, analyses });
|
|
384
|
+
let kind = "drafted";
|
|
385
|
+
if (blockingIssues(issues).length > 0) {
|
|
386
|
+
const revised = yield* reviseDesign(reasoning, { ...base, current: design, issues }).pipe(Effect.result);
|
|
387
|
+
if (revised._tag === "Success") {
|
|
388
|
+
design = revised.success;
|
|
389
|
+
issues = checkDesign(design, { catalog, operations, analyses });
|
|
390
|
+
kind = "revised";
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
yield* workspace.write(paths.design, renderDesignFile(design, issues, "proposed"));
|
|
394
|
+
const errors = blockingIssues(issues).length;
|
|
395
|
+
return { kind, approved: false, errors, warnings: issues.length - errors };
|
|
396
|
+
});
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
// Report
|
|
399
|
+
const outcomeText = (outcome) => outcome.kind === "called"
|
|
400
|
+
? `called: ${outcome.detail}${outcome.findings === 0 ? "" : `, ${outcome.findings} schema findings`}`
|
|
401
|
+
: `${outcome.kind}: ${outcome.detail}`;
|
|
402
|
+
const cell = (text) => text.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
403
|
+
export const renderExploreReport = (options) => {
|
|
404
|
+
const { service, catalog, result, design } = options;
|
|
405
|
+
const paths = servicePaths(service);
|
|
406
|
+
const lines = [
|
|
407
|
+
`# Explore: ${service}`,
|
|
408
|
+
"",
|
|
409
|
+
"Regenerated by every `soap-explore` run. Every value in the files it points at is masked.",
|
|
410
|
+
"",
|
|
411
|
+
`- operations: ${catalog.operations.length}, open questions: ${catalog.openQuestions.length} (see catalog.md)`,
|
|
412
|
+
`- environment: ${options.profile?.environment ?? "none (no auth.json)"}`,
|
|
413
|
+
`- operations.md: ${options.operations === undefined ? "missing" : options.operations.confirmed ? "confirmed" : "proposed"}`,
|
|
414
|
+
`- exchanges recorded: ${options.evidence.exchanges}`,
|
|
415
|
+
"",
|
|
416
|
+
"## Operations",
|
|
417
|
+
"",
|
|
418
|
+
"| Operation | Class | Probe | Request | Result |",
|
|
419
|
+
"| --- | --- | --- | --- | --- |"
|
|
420
|
+
];
|
|
421
|
+
for (const report of result.operations) {
|
|
422
|
+
const request = `samples/${report.operation}/${exploreRequestName} (${report.request})`;
|
|
423
|
+
lines.push(`| ${cell(report.operation)} | ${report.listed}${options.operations?.confirmed === true ? "" : " (proposed)"} | ${report.decision.probe ? `yes, ${report.decision.basis ?? ""}` : "no"} | ${cell(request)} | ${cell(report.decision.probe ? outcomeText(report.outcome) : report.outcome.kind === "recorded" ? outcomeText(report.outcome) : `not called: ${report.decision.reason}`)} |`);
|
|
424
|
+
}
|
|
425
|
+
const chained = result.operations.filter((report) => report.chained.length > 0);
|
|
426
|
+
if (chained.length > 0) {
|
|
427
|
+
lines.push("", "## Chained values", "");
|
|
428
|
+
for (const report of chained) {
|
|
429
|
+
lines.push(`- ${report.operation} ← ${report.chained.map((link) => `${link.producer} (${link.fields.join(", ")})`).join("; ")}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
lines.push("", "## Design", "");
|
|
433
|
+
switch (design.kind) {
|
|
434
|
+
case "skipped":
|
|
435
|
+
lines.push(`Not drafted: ${design.reason}.`);
|
|
436
|
+
break;
|
|
437
|
+
case "failed":
|
|
438
|
+
lines.push(`Not drafted: ${design.detail}`);
|
|
439
|
+
break;
|
|
440
|
+
default:
|
|
441
|
+
lines.push(`design/api-design.md ${design.kind}: ${design.approved ? "approved" : "proposed"}, ${design.errors} errors, ${design.warnings} warnings.`);
|
|
442
|
+
}
|
|
443
|
+
const next = [];
|
|
444
|
+
if (options.profile === undefined) {
|
|
445
|
+
next.push(`Write \`${paths.auth}\` to enable calls, at least \`{"environment": "test"}\` (dev, test, or uat; add "endpoint" when the WSDL names another environment, and auth by env:/file: reference). Then rerun soap-explore.`);
|
|
446
|
+
}
|
|
447
|
+
if (options.operations !== undefined && !options.operations.confirmed) {
|
|
448
|
+
next.push(`Review the classes in \`${paths.operations}\` and set \`Status: confirmed\`; then rerun soap-explore to probe every read operation.`);
|
|
449
|
+
}
|
|
450
|
+
for (const report of result.operations) {
|
|
451
|
+
if (report.outcome.kind === "failed") {
|
|
452
|
+
next.push(`${report.operation}: fix \`samples/${report.operation}/${exploreRequestName}.request.yaml\` (delete its first line to keep your edits), then \`soap-sample "call ${report.operation}/${exploreRequestName}"\`.`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const mutating = result.operations
|
|
456
|
+
.filter((report) => report.listed === "mutating" && report.outcome.kind === "not-called")
|
|
457
|
+
.map((report) => report.operation);
|
|
458
|
+
if (options.profile !== undefined && mutating.length > 0) {
|
|
459
|
+
next.push(`Mutating operations are never probed (${mutating.join(", ")}): edit a request, then \`soap-sample -- --allow-mutating <operation> "call <operation>/${exploreRequestName}"\` when a test call is acceptable.`);
|
|
460
|
+
}
|
|
461
|
+
if (options.evidence.exchanges > 0) {
|
|
462
|
+
next.push('More evidence per operation (empty result, business fault, paging): `soap-sample "propose <operation>"`, then `soap-sample "call <operation>"`, then rerun soap-explore.');
|
|
463
|
+
}
|
|
464
|
+
if (design.kind === "skipped" || design.kind === "failed") {
|
|
465
|
+
if (options.evidence.exchanges > 0)
|
|
466
|
+
next.push('Draft the design: `soap-design "design"`.');
|
|
467
|
+
}
|
|
468
|
+
else if (!design.approved) {
|
|
469
|
+
next.push('Review `design/api-design.md`, run `soap-design "check"` until it has no errors, set `Status: approved`, then `soap-design "openapi"` and `soap-epic`.');
|
|
470
|
+
}
|
|
471
|
+
else if (design.errors === 0) {
|
|
472
|
+
next.push('The design is approved: `soap-design "openapi"`, then `soap-epic -- --target <ace-repo> plan`.');
|
|
473
|
+
}
|
|
474
|
+
lines.push("", "## Next", "", ...next.map((line) => `- ${line}`), "");
|
|
475
|
+
return lines.join("\n");
|
|
476
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// One pass from a WSDL to a design draft: discover, a request for every operation, a read-only probe with values chained between calls, analyses, the 1:1 mapping, and a drafted REST design; stops only where a person decides.
|
|
2
|
+
//
|
|
3
|
+
// The task text is the WSDL location (path or http(s) URL), as for
|
|
4
|
+
// soap-discover; without one it continues from the discovered service:
|
|
5
|
+
//
|
|
6
|
+
// llm4ts run soap-explore --repo ~/work/conti-api ./wsdl/DemoBank.wsdl
|
|
7
|
+
// llm4ts run soap-explore --repo . # rerun: continue where it stopped
|
|
8
|
+
// llm4ts run soap-explore --repo . -- --refresh # call the probed operations again
|
|
9
|
+
//
|
|
10
|
+
// Steps, each keeping what already exists (so a rerun continues):
|
|
11
|
+
//
|
|
12
|
+
// 1. discover catalog.json/.md; operations.md with proposed classes
|
|
13
|
+
// 2. requests samples/<op>/happy-path.request.yaml for every operation
|
|
14
|
+
// 3. probe calls read operations only, producers first; values a
|
|
15
|
+
// response returns seed the next request (live in memory,
|
|
16
|
+
// masked on disk). Before operations.md is confirmed an
|
|
17
|
+
// operation is probed only in dev/test when the name
|
|
18
|
+
// heuristic and the judgment seat (LLM4TS_JUDGMENT_PROVIDER)
|
|
19
|
+
// both say read. Mutating operations are never called.
|
|
20
|
+
// 4. evidence analysis/<op>.md, design/mapping.json/.md
|
|
21
|
+
// 5. design the reasoning seat (LLM4TS_REASONER, default claude)
|
|
22
|
+
// drafts design/api-design.md, check, one revise round;
|
|
23
|
+
// an existing design is only checked. --no-design skips.
|
|
24
|
+
// 6. report explore.md: per operation what happened, and the next
|
|
25
|
+
// command for every gap
|
|
26
|
+
//
|
|
27
|
+
// Calls need auth.json (at least {"environment": "test"}); explore never
|
|
28
|
+
// writes it. Approval stays human: operations.md `Status: confirmed`,
|
|
29
|
+
// api-design.md `Status: approved`. LLM4TS_SOAP_SERVICE names the service
|
|
30
|
+
// (and the auth.json used to fetch a URL); LLM4TS_SOAP_STUB=<dir> answers
|
|
31
|
+
// calls from <dir>/<operation>.xml instead of the network.
|
|
32
|
+
import { readFile } from "node:fs/promises";
|
|
33
|
+
import { isAbsolute, resolve } from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
import * as Effect from "effect/Effect";
|
|
36
|
+
import { FlowAborted, Info, makeNodeWorkspace, mock, resolveFlowInput, runFlowMain, runNode, ScriptUsage } from "@llm4ts/runner";
|
|
37
|
+
import { coderFor, judgmentConnectorFromEnvironment } from "@llm4ts/runner/Connectors";
|
|
38
|
+
import { nodeSecretSource, resolveSide } from "./lib/soap/Auth.js";
|
|
39
|
+
import { heuristicClass, judgeClass } from "./lib/soap/Classification.js";
|
|
40
|
+
import { discoverService, loadAuthProfile, loadCatalog, loadOperationsFile } from "./lib/soap/Discover.js";
|
|
41
|
+
import { designStep, explorePaths, exploreService, renderExploreReport, writeEvidence } from "./lib/soap/Explore.js";
|
|
42
|
+
import { makeDirectoryStubTransport } from "./lib/soap/Stub.js";
|
|
43
|
+
import { makeNodeSoapTransport, makeTransportDocumentLoader } from "./lib/soap/Transport.js";
|
|
44
|
+
import { DocumentLoader } from "./lib/soap/Wsdl.js";
|
|
45
|
+
const usage = 'usage: soap-explore [--refresh] [--no-design] "<wsdl path or URL>"';
|
|
46
|
+
const isUrl = (location) => /^https?:\/\//i.test(location);
|
|
47
|
+
const parseExploreFlags = (argv) => Effect.gen(function* () {
|
|
48
|
+
let refresh = false;
|
|
49
|
+
let design = true;
|
|
50
|
+
const rest = [];
|
|
51
|
+
for (const argument of argv) {
|
|
52
|
+
if (argument === "--refresh")
|
|
53
|
+
refresh = true;
|
|
54
|
+
else if (argument === "--no-design")
|
|
55
|
+
design = false;
|
|
56
|
+
else if (argument.startsWith("--refresh=") || argument.startsWith("--no-design="))
|
|
57
|
+
return yield* ScriptUsage.make({ message: `${argument} takes no value\n${usage}` });
|
|
58
|
+
else
|
|
59
|
+
rest.push(argument);
|
|
60
|
+
}
|
|
61
|
+
return { refresh, design, rest };
|
|
62
|
+
});
|
|
63
|
+
const styleGuide = () => Effect.tryPromise({
|
|
64
|
+
try: () => readFile(fileURLToPath(new URL("../packs/ace12-rest/api-style.md", import.meta.url)), "utf8"),
|
|
65
|
+
catch: () => FlowAborted.make({ message: "cannot read the kit's packs/ace12-rest/api-style.md" })
|
|
66
|
+
});
|
|
67
|
+
const program = Effect.gen(function* () {
|
|
68
|
+
const flags = yield* parseExploreFlags(process.argv.slice(2));
|
|
69
|
+
const input = yield* resolveFlowInput("", flags.rest);
|
|
70
|
+
const raw = input.prompt.trim();
|
|
71
|
+
const location = raw === "" ? undefined : isUrl(raw) || isAbsolute(raw) ? raw : resolve(input.workspace, raw);
|
|
72
|
+
const environment = process.env;
|
|
73
|
+
const requested = environment.LLM4TS_SOAP_SERVICE?.trim() || undefined;
|
|
74
|
+
const judgment = yield* judgmentConnectorFromEnvironment();
|
|
75
|
+
const reasonerName = (environment.LLM4TS_REASONER ?? "claude").trim() || "claude";
|
|
76
|
+
const reasoner = flags.design ? coderFor(reasonerName) : undefined;
|
|
77
|
+
if (flags.design && reasoner === undefined) {
|
|
78
|
+
return yield* ScriptUsage.make({ message: `unknown LLM4TS_REASONER '${reasonerName}'` });
|
|
79
|
+
}
|
|
80
|
+
yield* runNode({
|
|
81
|
+
workDir: input.workDir,
|
|
82
|
+
workspace: input.workspace,
|
|
83
|
+
userPrompt: input.prompt,
|
|
84
|
+
coder: mock,
|
|
85
|
+
...(reasoner === undefined ? {} : { reasoning: reasoner }),
|
|
86
|
+
...(judgment === undefined ? {} : { judgment }),
|
|
87
|
+
environment
|
|
88
|
+
}, (context) => Effect.gen(function* () {
|
|
89
|
+
const say = (message) => context.events.publish(Info.make({ message }));
|
|
90
|
+
const workspace = yield* makeNodeWorkspace(input.workDir);
|
|
91
|
+
const seat = judgment === undefined ? undefined : context.judgment;
|
|
92
|
+
// 1. discover (or continue from the discovered service)
|
|
93
|
+
let service;
|
|
94
|
+
let catalog;
|
|
95
|
+
let judged;
|
|
96
|
+
if (location !== undefined) {
|
|
97
|
+
const fetchProfile = requested === undefined ? undefined : yield* loadAuthProfile(workspace, requested);
|
|
98
|
+
const fetchSide = yield* resolveSide(nodeSecretSource(environment), fetchProfile?.fetch);
|
|
99
|
+
const discovered = yield* discoverService({
|
|
100
|
+
workspace,
|
|
101
|
+
location,
|
|
102
|
+
...(requested === undefined ? {} : { service: requested }),
|
|
103
|
+
...(seat === undefined ? {} : { judgment: seat })
|
|
104
|
+
}).pipe(Effect.provideService(DocumentLoader, makeTransportDocumentLoader(makeNodeSoapTransport(), fetchSide)));
|
|
105
|
+
service = discovered.service;
|
|
106
|
+
catalog = discovered.catalog;
|
|
107
|
+
// Classes judged at discovery are reused; otherwise judged below.
|
|
108
|
+
if (discovered.proposals !== undefined && seat !== undefined) {
|
|
109
|
+
judged = new Map(discovered.proposals.map((proposal) => [proposal.operation, proposal.judged]));
|
|
110
|
+
}
|
|
111
|
+
yield* say(`discovered ${service}: ${catalog.operations.length} operations, ${catalog.openQuestions.length} open questions`);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const services = (yield* workspace.discover(".llm4ts/soap/*/catalog.json")).map((path) => path.split("/")[2] ?? "");
|
|
115
|
+
const picked = requested ?? (services.length === 1 ? services[0] : undefined);
|
|
116
|
+
if (picked === undefined) {
|
|
117
|
+
return yield* FlowAborted.make({
|
|
118
|
+
message: services.length === 0
|
|
119
|
+
? `nothing discovered here; give the WSDL\n${usage}`
|
|
120
|
+
: `several services (${services.join(", ")}); set LLM4TS_SOAP_SERVICE`
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
service = picked;
|
|
124
|
+
catalog = yield* loadCatalog(workspace, service);
|
|
125
|
+
yield* say(`continuing ${service}: ${catalog.operations.length} operations`);
|
|
126
|
+
}
|
|
127
|
+
const profile = yield* loadAuthProfile(workspace, service);
|
|
128
|
+
const operations = yield* loadOperationsFile(workspace, service);
|
|
129
|
+
// Provisional probing asks the judgment only where the listed class
|
|
130
|
+
// and the heuristic already say read.
|
|
131
|
+
if (seat !== undefined && judged === undefined && operations?.confirmed !== true) {
|
|
132
|
+
const candidates = catalog.operations.filter((operation) => operations?.classes.get(operation.name) === "read" &&
|
|
133
|
+
heuristicClass(operation.name).class === "read");
|
|
134
|
+
judged = new Map(yield* Effect.forEach(candidates, (operation) => Effect.map(judgeClass(seat, catalog, operation), (answer) => [operation.name, answer])));
|
|
135
|
+
}
|
|
136
|
+
// 2-3. requests and the probe
|
|
137
|
+
const stub = environment.LLM4TS_SOAP_STUB?.trim();
|
|
138
|
+
const transport = stub === undefined || stub === ""
|
|
139
|
+
? makeNodeSoapTransport()
|
|
140
|
+
: makeDirectoryStubTransport(resolve(input.workspace, stub), catalog);
|
|
141
|
+
if (stub !== undefined && stub !== "")
|
|
142
|
+
yield* say(`LLM4TS_SOAP_STUB: answering from ${stub}, not the network`);
|
|
143
|
+
const result = yield* exploreService({
|
|
144
|
+
workspace,
|
|
145
|
+
catalog,
|
|
146
|
+
service,
|
|
147
|
+
profile,
|
|
148
|
+
operations,
|
|
149
|
+
judged,
|
|
150
|
+
secrets: nodeSecretSource(environment),
|
|
151
|
+
transport,
|
|
152
|
+
refresh: flags.refresh,
|
|
153
|
+
progress: (line) => say(` ${line}`)
|
|
154
|
+
});
|
|
155
|
+
const probed = result.decisions.filter((decision) => decision.probe).length;
|
|
156
|
+
yield* say(`probe: ${probed} of ${catalog.operations.length} operations callable now${profile === undefined ? " (no auth.json)" : ""}`);
|
|
157
|
+
// 4. evidence
|
|
158
|
+
const evidence = yield* writeEvidence(workspace, catalog, service);
|
|
159
|
+
// 5. design
|
|
160
|
+
const design = yield* designStep({
|
|
161
|
+
workspace,
|
|
162
|
+
catalog,
|
|
163
|
+
service,
|
|
164
|
+
operations,
|
|
165
|
+
evidence,
|
|
166
|
+
reasoning: flags.design ? context.reasoning : { off: "--no-design" },
|
|
167
|
+
style: yield* styleGuide()
|
|
168
|
+
});
|
|
169
|
+
yield* say(design.kind === "skipped" || design.kind === "failed"
|
|
170
|
+
? `design: not drafted (${design.kind === "skipped" ? design.reason : design.detail})`
|
|
171
|
+
: `design: ${design.kind}, ${design.approved ? "approved" : "proposed"}, ${design.errors} errors, ${design.warnings} warnings`);
|
|
172
|
+
// 6. report
|
|
173
|
+
const report = renderExploreReport({
|
|
174
|
+
service,
|
|
175
|
+
catalog,
|
|
176
|
+
profile,
|
|
177
|
+
operations,
|
|
178
|
+
result,
|
|
179
|
+
design,
|
|
180
|
+
evidence
|
|
181
|
+
});
|
|
182
|
+
const path = explorePaths(service).report;
|
|
183
|
+
yield* workspace.write(path, report);
|
|
184
|
+
const next = report.split("## Next")[1]?.trim().split("\n") ?? [];
|
|
185
|
+
yield* say(`wrote ${path}; next:`);
|
|
186
|
+
for (const line of next)
|
|
187
|
+
yield* say(` ${line}`);
|
|
188
|
+
}));
|
|
189
|
+
});
|
|
190
|
+
runFlowMain(program);
|