@catheadowl/ukp 0.1.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/LICENSE +21 -0
- package/README.md +208 -0
- package/bun.lock +35 -0
- package/package.json +51 -0
- package/src/artifacts.ts +75 -0
- package/src/capabilities/nav.ts +734 -0
- package/src/capabilities/propose.ts +456 -0
- package/src/capabilities/qmd.ts +174 -0
- package/src/capabilities/read.ts +1040 -0
- package/src/capabilities/remote-client.ts +856 -0
- package/src/capabilities/rename-recovery.ts +294 -0
- package/src/capabilities/rg.ts +505 -0
- package/src/capabilities/search.ts +1105 -0
- package/src/capabilities/tls-identity.ts +87 -0
- package/src/capabilities/update.ts +385 -0
- package/src/cli.ts +425 -0
- package/src/commands/diagnose.ts +383 -0
- package/src/commands/flags.ts +49 -0
- package/src/commands/guide-content/client.txt +36 -0
- package/src/commands/guide-content/propose.txt +38 -0
- package/src/commands/guide-content/service-qmd.txt +44 -0
- package/src/commands/guide-content/service.txt +114 -0
- package/src/commands/guide.ts +121 -0
- package/src/commands/init.ts +245 -0
- package/src/commands/inspect.ts +182 -0
- package/src/commands/inventory.ts +570 -0
- package/src/commands/kit.ts +269 -0
- package/src/commands/nav.ts +280 -0
- package/src/commands/propose.ts +257 -0
- package/src/commands/read.ts +558 -0
- package/src/commands/rg.ts +355 -0
- package/src/commands/search.ts +325 -0
- package/src/commands/serve.ts +221 -0
- package/src/commands/update.ts +99 -0
- package/src/config/client.ts +55 -0
- package/src/config/external-tool.ts +58 -0
- package/src/config/file-native.ts +111 -0
- package/src/config/manifest.ts +188 -0
- package/src/fslock.ts +95 -0
- package/src/path-safety.ts +65 -0
- package/src/registry.ts +436 -0
- package/src/scope.ts +78 -0
- package/src/server.ts +1011 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,1105 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
openSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
statSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { join, relative, resolve } from "node:path";
|
|
13
|
+
import { createArtifactRun } from "../artifacts.ts";
|
|
14
|
+
import {
|
|
15
|
+
loadManifest,
|
|
16
|
+
ManifestError,
|
|
17
|
+
type LoadedManifest,
|
|
18
|
+
type ManifestDependency,
|
|
19
|
+
} from "../config/manifest.ts";
|
|
20
|
+
import { isRemoteBinding, localPathOf, readRegistry, type RegistryBinding } from "../registry.ts";
|
|
21
|
+
import { resolveScope } from "../scope.ts";
|
|
22
|
+
import { isAbsoluteShapedPath, isInsideRealRoot } from "../path-safety.ts";
|
|
23
|
+
import { buildQmdInvocation, defaultQmdCommand, isDocidBody, providerTimeoutMs, stripDocidHash } from "./qmd.ts";
|
|
24
|
+
|
|
25
|
+
export interface SearchRequest {
|
|
26
|
+
query: string;
|
|
27
|
+
limit: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SearchOptions {
|
|
31
|
+
explicitEndpoints?: string[];
|
|
32
|
+
global: boolean;
|
|
33
|
+
recursive: boolean;
|
|
34
|
+
json: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ParsedSearch {
|
|
38
|
+
request: SearchRequest;
|
|
39
|
+
options: SearchOptions;
|
|
40
|
+
warnings: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class SearchPlanningError extends Error {
|
|
44
|
+
constructor(message: string) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "SearchPlanningError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** ADR 0021 context injection (registry path, provider command, artifact
|
|
51
|
+
* root/run id, clock) — no ambient state. */
|
|
52
|
+
export interface SearchContext {
|
|
53
|
+
currentDirectory: string;
|
|
54
|
+
registryPath: string;
|
|
55
|
+
qmdCommand?: readonly string[];
|
|
56
|
+
artifactRoot?: string;
|
|
57
|
+
artifactRunId?: string;
|
|
58
|
+
now?: Date;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** ADR 0021 aggregate classification (D-036: skip never changes the success
|
|
62
|
+
* state; a provider failure makes the whole run exit non-zero). The surface
|
|
63
|
+
* adapter maps this onto exit codes — the capability never decides them. */
|
|
64
|
+
export type SearchAggregateStatus =
|
|
65
|
+
| "succeeded"
|
|
66
|
+
| "provider-failure"
|
|
67
|
+
| "cancelled"
|
|
68
|
+
| "no-success";
|
|
69
|
+
|
|
70
|
+
/** One endpoint's structured result. Contract zone: name/provider/status/
|
|
71
|
+
* message plus the json-mode artifact fields. Provider-native zone: the raw
|
|
72
|
+
* provider output and exit status (human-mode render source, passed through
|
|
73
|
+
* unmodeled per ADR 0021 §1). `folder` is internal provenance for the human
|
|
74
|
+
* renderer (ukp:// derivation), never part of the envelope. */
|
|
75
|
+
export interface SearchEndpointOutcome {
|
|
76
|
+
name: string;
|
|
77
|
+
provider: string | null;
|
|
78
|
+
/** Undefined only transiently while the run classifies the provider
|
|
79
|
+
* result; every path assigns a final status before the outcome escapes
|
|
80
|
+
* (the envelope projection defaults any stray undefined to `failed`). */
|
|
81
|
+
status?:
|
|
82
|
+
| "succeeded"
|
|
83
|
+
| "no_matches"
|
|
84
|
+
| "skipped"
|
|
85
|
+
| "failed"
|
|
86
|
+
| "cancelled"
|
|
87
|
+
/** human mode only: this endpoint's SIGINT stopped the run — its header
|
|
88
|
+
* was already rendered, its result block never is. */
|
|
89
|
+
| "interrupted";
|
|
90
|
+
/** Factual skip/failure message (envelope `message`), no surface prefix. */
|
|
91
|
+
message?: string;
|
|
92
|
+
traversal?: TraversalProvenance;
|
|
93
|
+
folder?: string;
|
|
94
|
+
/** Raw provider stdout, trimmed (human-mode render source). */
|
|
95
|
+
providerOutput?: string;
|
|
96
|
+
/** Raw provider exit status (null on signal or spawn error). */
|
|
97
|
+
providerExitStatus?: number | null;
|
|
98
|
+
/** Provider stderr detail or spawn error message. */
|
|
99
|
+
providerErrorDetail?: string;
|
|
100
|
+
artifact?: string;
|
|
101
|
+
format?: string;
|
|
102
|
+
references_artifact?: string;
|
|
103
|
+
references_format?: "ukp-search-references-v1";
|
|
104
|
+
error_artifact?: string;
|
|
105
|
+
/** Remote endpoints only (ukp_remote W2): server-declared `ukp_uri` per
|
|
106
|
+
* result index (RQ-06/RQ-09 — the server owns containment). Internal
|
|
107
|
+
* renderer input like `folder`, never part of the envelope. */
|
|
108
|
+
remoteUris?: ReadonlyArray<string | undefined>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** ADR 0021 structured outcome: everything both render modes and future
|
|
112
|
+
* surfaces (MCP, programmatic) consume. No stdout/stderr/exit codes. */
|
|
113
|
+
export interface SearchResult {
|
|
114
|
+
query: string;
|
|
115
|
+
limit: number;
|
|
116
|
+
/** Present only when the run produced an artifact directory (json mode). */
|
|
117
|
+
runId?: string;
|
|
118
|
+
endpoints: SearchEndpointOutcome[];
|
|
119
|
+
warnings: string[];
|
|
120
|
+
aggregate: SearchAggregateStatus;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface PlannedEndpoint {
|
|
124
|
+
name: string;
|
|
125
|
+
provider: string | null;
|
|
126
|
+
status: "executable" | "skipped";
|
|
127
|
+
folder?: string;
|
|
128
|
+
command?: readonly string[];
|
|
129
|
+
warning?: string;
|
|
130
|
+
traversal?: TraversalProvenance;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface TraversalVia {
|
|
134
|
+
kind: ManifestDependency["kind"];
|
|
135
|
+
reason?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface TraversalProvenance {
|
|
139
|
+
depth: 0 | 1;
|
|
140
|
+
path: string[];
|
|
141
|
+
via: TraversalVia | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isStaleServiceBinding(error: unknown): boolean {
|
|
145
|
+
if (!(error instanceof ManifestError)) return false;
|
|
146
|
+
return error.message.startsWith("Service folder is not accessible:")
|
|
147
|
+
|| error.message.startsWith("Service Manifest is not readable:");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface SearchEndpointEnvelope {
|
|
151
|
+
name: string;
|
|
152
|
+
provider: string | null;
|
|
153
|
+
status: "succeeded" | "no_matches" | "skipped" | "failed" | "cancelled";
|
|
154
|
+
artifact?: string;
|
|
155
|
+
format?: string;
|
|
156
|
+
references_artifact?: string;
|
|
157
|
+
references_format?: "ukp-search-references-v1";
|
|
158
|
+
error_artifact?: string;
|
|
159
|
+
message?: string;
|
|
160
|
+
depth?: 0 | 1;
|
|
161
|
+
path?: string[];
|
|
162
|
+
via?: TraversalVia | null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface SearchEnvelope {
|
|
166
|
+
schema: "ukp.search.v1";
|
|
167
|
+
run_id: string;
|
|
168
|
+
command: "search";
|
|
169
|
+
capability: "search";
|
|
170
|
+
query: string;
|
|
171
|
+
limit: number;
|
|
172
|
+
endpoints: SearchEndpointEnvelope[];
|
|
173
|
+
warnings: string[];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function planSearch(parsed: ParsedSearch, context: SearchContext): {
|
|
177
|
+
plan: PlannedEndpoint[];
|
|
178
|
+
warnings: string[];
|
|
179
|
+
} {
|
|
180
|
+
const registry = readRegistry(context.registryPath);
|
|
181
|
+
const scope = resolveScope({
|
|
182
|
+
currentDirectory: context.currentDirectory,
|
|
183
|
+
registry,
|
|
184
|
+
explicitEndpoints: parsed.options.explicitEndpoints,
|
|
185
|
+
global: parsed.options.global,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const qmdCommand = context.qmdCommand ?? defaultQmdCommand();
|
|
189
|
+
const warnings = [...parsed.warnings, ...scope.warnings];
|
|
190
|
+
const plan: PlannedEndpoint[] = [];
|
|
191
|
+
const loadedServices = new Map<string, LoadedManifest>();
|
|
192
|
+
|
|
193
|
+
const planBinding = (
|
|
194
|
+
binding: RegistryBinding,
|
|
195
|
+
traversal?: TraversalProvenance,
|
|
196
|
+
): PlannedEndpoint => {
|
|
197
|
+
if (isRemoteBinding(binding)) {
|
|
198
|
+
// Adapter responsibility: remote endpoints execute through the remote
|
|
199
|
+
// transport (commands/search.ts mixed driver); the local planner only
|
|
200
|
+
// records the skip defensively.
|
|
201
|
+
return {
|
|
202
|
+
name: binding.name,
|
|
203
|
+
provider: null,
|
|
204
|
+
status: "skipped",
|
|
205
|
+
warning: "remote endpoints execute via the remote transport; the local planner skipped it",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
let service;
|
|
209
|
+
try {
|
|
210
|
+
service = loadManifest(localPathOf(binding));
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (!isStaleServiceBinding(error)) throw error;
|
|
213
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
214
|
+
const warning = [
|
|
215
|
+
`endpoint '${binding.name}' is not accessible: ${detail}`,
|
|
216
|
+
`Hint: run 'ukp inspect --endpoint ${binding.name}' or re-register/remove the stale endpoint.`,
|
|
217
|
+
].join("\n");
|
|
218
|
+
warnings.push(warning);
|
|
219
|
+
return { name: binding.name, provider: null, status: "skipped", warning, traversal };
|
|
220
|
+
}
|
|
221
|
+
if (service.effectiveName !== binding.name) {
|
|
222
|
+
throw new SearchPlanningError(
|
|
223
|
+
`endpoint '${binding.name}' no longer matches Service effective name '${service.effectiveName}'`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
loadedServices.set(binding.name, service);
|
|
227
|
+
const capability = service.manifest.capabilities.search;
|
|
228
|
+
if (!capability) {
|
|
229
|
+
const warning = `endpoint '${binding.name}' does not provide search`;
|
|
230
|
+
warnings.push(warning);
|
|
231
|
+
return { name: binding.name, provider: null, status: "skipped", warning, traversal };
|
|
232
|
+
}
|
|
233
|
+
if (capability.provider !== "qmd") {
|
|
234
|
+
const provider = capability.provider ?? "(none)";
|
|
235
|
+
const warning = `endpoint '${binding.name}' uses unsupported search provider '${provider}'`;
|
|
236
|
+
warnings.push(warning);
|
|
237
|
+
return {
|
|
238
|
+
name: binding.name,
|
|
239
|
+
provider,
|
|
240
|
+
status: "skipped",
|
|
241
|
+
warning,
|
|
242
|
+
traversal,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (!qmdCommand) {
|
|
246
|
+
const warning = `endpoint '${binding.name}' search unavailable: qmd executable is not available`;
|
|
247
|
+
warnings.push(warning);
|
|
248
|
+
return { name: binding.name, provider: "qmd", status: "skipped", warning, traversal };
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
name: binding.name,
|
|
252
|
+
provider: "qmd",
|
|
253
|
+
status: "executable",
|
|
254
|
+
folder: service.folder,
|
|
255
|
+
command: qmdCommand,
|
|
256
|
+
traversal,
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
for (const binding of scope.bindings) {
|
|
261
|
+
plan.push(planBinding(binding, parsed.options.recursive
|
|
262
|
+
? { depth: 0, path: [binding.name], via: null }
|
|
263
|
+
: undefined));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Dangling `default_endpoints` references never resolve to a binding, so they
|
|
267
|
+
// produce a skipped plan entry at their declared position (not only a warning
|
|
268
|
+
// string) — a JSON consumer must be able to count every skipped endpoint.
|
|
269
|
+
for (const { name, configPath, index } of scope.dangling) {
|
|
270
|
+
const warning = `'${name}' is not registered (from ${configPath})`;
|
|
271
|
+
// `scope.bindings` and `scope.dangling` each preserve declaration order, and
|
|
272
|
+
// each binding produced exactly one plan entry above, so the plan has
|
|
273
|
+
// `index` entries before this dangling reference's declared position.
|
|
274
|
+
plan.splice(index, 0, {
|
|
275
|
+
name,
|
|
276
|
+
provider: null,
|
|
277
|
+
status: "skipped",
|
|
278
|
+
warning,
|
|
279
|
+
...(parsed.options.recursive
|
|
280
|
+
? { traversal: { depth: 0 as const, path: [name], via: null } }
|
|
281
|
+
: {}),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (scope.dangling.length > 0 && scope.configPath) {
|
|
286
|
+
warnings.push(`Hint: run 'ukp list' or 'ukp diagnose' to check the selected scope, or edit ${scope.configPath}, then retry.`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (parsed.options.recursive) {
|
|
290
|
+
const registryByName = new Map(registry.map((binding) => [binding.name, binding]));
|
|
291
|
+
const visited = new Set(plan.map((endpoint) => endpoint.name));
|
|
292
|
+
const discovered: PlannedEndpoint[] = [];
|
|
293
|
+
|
|
294
|
+
for (const binding of scope.bindings) {
|
|
295
|
+
const service = loadedServices.get(binding.name);
|
|
296
|
+
if (!service) continue;
|
|
297
|
+
const dependencies = [...(service.manifest.dependencies ?? [])]
|
|
298
|
+
.filter((dependency) => dependency.kind === "authority" || dependency.kind === "context")
|
|
299
|
+
.sort((left, right) => left.endpoint < right.endpoint ? -1 : left.endpoint > right.endpoint ? 1 : 0);
|
|
300
|
+
for (const dependency of dependencies) {
|
|
301
|
+
if (visited.has(dependency.endpoint)) continue;
|
|
302
|
+
const targetBinding = registryByName.get(dependency.endpoint);
|
|
303
|
+
if (!targetBinding) {
|
|
304
|
+
warnings.push(
|
|
305
|
+
`recursive dependency target '${dependency.endpoint}' is not registered `
|
|
306
|
+
+ `(declared by ${binding.name}, kind: ${dependency.kind})`,
|
|
307
|
+
);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
visited.add(dependency.endpoint);
|
|
311
|
+
discovered.push(planBinding(targetBinding, {
|
|
312
|
+
depth: 1,
|
|
313
|
+
path: [binding.name, dependency.endpoint],
|
|
314
|
+
via: {
|
|
315
|
+
kind: dependency.kind,
|
|
316
|
+
...(dependency.reason ? { reason: dependency.reason } : {}),
|
|
317
|
+
},
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
plan.push(...discovered);
|
|
322
|
+
|
|
323
|
+
for (const endpoint of discovered) {
|
|
324
|
+
const service = loadedServices.get(endpoint.name);
|
|
325
|
+
if (!service || !endpoint.traversal) continue;
|
|
326
|
+
for (const dependency of service.manifest.dependencies ?? []) {
|
|
327
|
+
if (dependency.kind !== "authority" && dependency.kind !== "context") continue;
|
|
328
|
+
if (!endpoint.traversal.path.includes(dependency.endpoint)) continue;
|
|
329
|
+
warnings.push(
|
|
330
|
+
`recursive dependency cycle truncated: ${endpoint.name} -> ${dependency.endpoint} `
|
|
331
|
+
+ `(kind: ${dependency.kind}, path: ${endpoint.traversal.path.join(" -> ")})`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (!plan.some((endpoint) => endpoint.status === "executable")) {
|
|
338
|
+
warnings.push(registry.length === 0
|
|
339
|
+
? "no executable search endpoints: the Host Registry is empty; run 'ukp register' from a Service folder, then retry"
|
|
340
|
+
: "no executable search endpoints: run 'ukp list' to inspect registrations and 'ukp diagnose' from the selected Service folder(s)");
|
|
341
|
+
}
|
|
342
|
+
return { plan, warnings };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function commandFor(endpoint: PlannedEndpoint, parsed: ParsedSearch, json: boolean): {
|
|
346
|
+
file: string;
|
|
347
|
+
args: string[];
|
|
348
|
+
verbatim: boolean;
|
|
349
|
+
} {
|
|
350
|
+
const providerArgs = [
|
|
351
|
+
"search",
|
|
352
|
+
parsed.request.query,
|
|
353
|
+
"-n",
|
|
354
|
+
String(parsed.request.limit),
|
|
355
|
+
];
|
|
356
|
+
if (json) providerArgs.push("--format", "json");
|
|
357
|
+
// The query is arbitrary user text: route through buildQmdInvocation so a
|
|
358
|
+
// cmd.exe shim wrapper never lets cmd re-parse it.
|
|
359
|
+
return buildQmdInvocation(endpoint.command!, providerArgs);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function renderTraversalProvenance(traversal: TraversalProvenance | undefined): string[] {
|
|
363
|
+
if (!traversal) return [];
|
|
364
|
+
const via = traversal.via ? ` via=${traversal.via.kind}` : "";
|
|
365
|
+
const lines = [
|
|
366
|
+
`traversal: depth=${traversal.depth} path=${traversal.path.join(" -> ")}${via}`,
|
|
367
|
+
];
|
|
368
|
+
if (traversal.via?.reason) lines.push(`traversal_reason: ${traversal.via.reason}`);
|
|
369
|
+
return lines;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function runHumanMode(
|
|
373
|
+
parsed: ParsedSearch,
|
|
374
|
+
plan: readonly PlannedEndpoint[],
|
|
375
|
+
warnings: string[],
|
|
376
|
+
): SearchResult {
|
|
377
|
+
const executable = plan.filter((endpoint) => endpoint.status === "executable");
|
|
378
|
+
const endpoints: SearchEndpointOutcome[] = [];
|
|
379
|
+
if (executable.length === 0) {
|
|
380
|
+
return {
|
|
381
|
+
query: parsed.request.query,
|
|
382
|
+
limit: parsed.request.limit,
|
|
383
|
+
endpoints,
|
|
384
|
+
warnings,
|
|
385
|
+
aggregate: "no-success",
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let failed = false;
|
|
390
|
+
let interrupted = false;
|
|
391
|
+
for (const endpoint of plan) {
|
|
392
|
+
if (endpoint.status === "skipped") {
|
|
393
|
+
endpoints.push({
|
|
394
|
+
name: endpoint.name,
|
|
395
|
+
provider: endpoint.provider,
|
|
396
|
+
status: "skipped",
|
|
397
|
+
message: endpoint.warning,
|
|
398
|
+
...(endpoint.traversal ? { traversal: endpoint.traversal } : {}),
|
|
399
|
+
});
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (interrupted) {
|
|
403
|
+
// Executables after the SIGINT stop: recorded as cancelled so a JSON
|
|
404
|
+
// consumer can count them, never rendered in human mode.
|
|
405
|
+
endpoints.push({
|
|
406
|
+
name: endpoint.name,
|
|
407
|
+
provider: endpoint.provider,
|
|
408
|
+
status: "cancelled",
|
|
409
|
+
...(endpoint.traversal ? { traversal: endpoint.traversal } : {}),
|
|
410
|
+
});
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const command = commandFor(endpoint, parsed, true);
|
|
415
|
+
const result = spawnSync(command.file, command.args, {
|
|
416
|
+
cwd: endpoint.folder!,
|
|
417
|
+
encoding: "utf8",
|
|
418
|
+
windowsHide: true,
|
|
419
|
+
windowsVerbatimArguments: command.verbatim,
|
|
420
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
421
|
+
timeout: providerTimeoutMs(),
|
|
422
|
+
});
|
|
423
|
+
const providerOutput = (result.stdout ?? "").trimEnd();
|
|
424
|
+
const outcome: SearchEndpointOutcome = {
|
|
425
|
+
name: endpoint.name,
|
|
426
|
+
provider: endpoint.provider,
|
|
427
|
+
...(endpoint.traversal ? { traversal: endpoint.traversal } : {}),
|
|
428
|
+
folder: endpoint.folder,
|
|
429
|
+
providerOutput,
|
|
430
|
+
providerExitStatus: result.status,
|
|
431
|
+
};
|
|
432
|
+
endpoints.push(outcome);
|
|
433
|
+
if (result.signal === "SIGTERM") {
|
|
434
|
+
// Zero-output hang guard: a timed-out provider is a classified failure,
|
|
435
|
+
// never silence (see qmd.ts providerTimeoutMs).
|
|
436
|
+
failed = true;
|
|
437
|
+
const message = `endpoint '${endpoint.name}' provider timed out after ${providerTimeoutMs() / 1000}s (set UKP_PROVIDER_TIMEOUT_MS to adjust)`;
|
|
438
|
+
warnings.push(message);
|
|
439
|
+
outcome.status = "failed";
|
|
440
|
+
outcome.providerExitStatus = null;
|
|
441
|
+
outcome.message = `endpoint '${endpoint.name}' provider timed out`;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (result.signal === "SIGINT") {
|
|
445
|
+
warnings.push(`endpoint '${endpoint.name}' provider cancelled`);
|
|
446
|
+
outcome.status = "interrupted";
|
|
447
|
+
outcome.providerExitStatus = null;
|
|
448
|
+
interrupted = true;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
if (result.status !== 0 || result.error) {
|
|
452
|
+
failed = true;
|
|
453
|
+
const providerError = (result.stderr ?? "").trimEnd() || result.error?.message;
|
|
454
|
+
warnings.push(`endpoint '${endpoint.name}' provider failed${providerError ? `: ${providerError}` : ""}`);
|
|
455
|
+
outcome.status = "failed";
|
|
456
|
+
outcome.providerErrorDetail = providerError;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
outcome.status = providerOutput ? "succeeded" : "no_matches";
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
query: parsed.request.query,
|
|
463
|
+
limit: parsed.request.limit,
|
|
464
|
+
endpoints,
|
|
465
|
+
warnings,
|
|
466
|
+
aggregate: interrupted ? "cancelled" : failed ? "provider-failure" : "succeeded",
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function appendErrorArtifact(path: string, message: string): void {
|
|
471
|
+
const previous = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
472
|
+
writeFileSync(path, `${previous}${previous && !previous.endsWith("\n") ? "\n" : ""}${message}\n`, {
|
|
473
|
+
encoding: "utf8",
|
|
474
|
+
mode: 0o600,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function classifyQmdArtifact(path: string): "succeeded" | "no_matches" {
|
|
479
|
+
const size = statSync(path).size;
|
|
480
|
+
if (size > 1024 * 1024) return "succeeded";
|
|
481
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
482
|
+
return Array.isArray(value) && value.length === 0 ? "no_matches" : "succeeded";
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
interface QmdReferenceMapping {
|
|
486
|
+
provider_location: string;
|
|
487
|
+
endpoint: string;
|
|
488
|
+
reference?: string;
|
|
489
|
+
line?: number;
|
|
490
|
+
status: "read_ready" | "provider_only";
|
|
491
|
+
read_adapter?: "qmd";
|
|
492
|
+
ukp_uri?: string;
|
|
493
|
+
reason?: string;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
interface QmdReferenceSidecar {
|
|
497
|
+
schema: "ukp.search.references.v1";
|
|
498
|
+
endpoint: string;
|
|
499
|
+
source_artifact: string;
|
|
500
|
+
results: Array<QmdReferenceMapping & { index: number }>;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function maybeLine(line: number | undefined): { line?: number } {
|
|
504
|
+
return line ? { line } : {};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function docidOf(result: unknown): string | undefined {
|
|
508
|
+
if (typeof result !== "object" || result === null || !("docid" in result)) return undefined;
|
|
509
|
+
const docid = (result as { docid?: unknown }).docid;
|
|
510
|
+
if (typeof docid !== "string") return undefined;
|
|
511
|
+
const bare = stripDocidHash(docid);
|
|
512
|
+
return isDocidBody(bare) ? bare : undefined;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function lineOf(result: unknown): number | undefined {
|
|
516
|
+
if (typeof result !== "object" || result === null || !("line" in result)) return undefined;
|
|
517
|
+
const line = (result as { line?: unknown }).line;
|
|
518
|
+
return Number.isSafeInteger(line) && (line as number) > 0 ? (line as number) : undefined;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Exported for the rename-recovery L2 re-anchor (ADR 0020): extracts the
|
|
522
|
+
* provider location (`uri`/`file` field) of one parsed QMD result object. */
|
|
523
|
+
export function providerLocationOf(result: unknown): string {
|
|
524
|
+
if (typeof result !== "object" || result === null) return "";
|
|
525
|
+
const record = result as { uri?: unknown; file?: unknown };
|
|
526
|
+
if (typeof record.uri === "string") return record.uri;
|
|
527
|
+
if (typeof record.file === "string") return record.file;
|
|
528
|
+
return "";
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Percent-encode a `ukp://` path segment for emission (ADR 0019; the read
|
|
533
|
+
* side percent-decodes symmetrically). Raw UTF-8 stays raw (grep-ability, IRI semantics);
|
|
534
|
+
* only characters that cannot round-trip through the hierarchical form raw are
|
|
535
|
+
* encoded: `%` (would be re-decoded on read), space, and the `#`/`?` delimiters.
|
|
536
|
+
*/
|
|
537
|
+
function encodeUkpUriSegment(segment: string): string {
|
|
538
|
+
return segment.replace(/[%#? ]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Derive the endpoint-relative path of one QMD result location (ADR 0019).
|
|
543
|
+
*
|
|
544
|
+
* The provider location is either path-shaped (`qmd://<absolute path>` — the
|
|
545
|
+
* collection root is a filesystem path) or collection-shaped
|
|
546
|
+
* (`qmd://<collection>/<rel>`). Only a location that resolves *inside* the
|
|
547
|
+
* Service folder to an existing regular file yields a `ukp://` URI: the URI is
|
|
548
|
+
* a slot promise (`ukp read` resolves it exactly), so it must never be emitted
|
|
549
|
+
* for provider-managed or out-of-folder resources. Collection names are never
|
|
550
|
+
* assumed to equal endpoint names — the resolved-containment + existence check,
|
|
551
|
+
* not name matching, decides (lexical containment plus a realpath pass so
|
|
552
|
+
* symlink escapes cannot yield an unreadable slot, mirroring the read hit path).
|
|
553
|
+
*/
|
|
554
|
+
/** Exported for the rename-recovery L2 re-anchor (ADR 0020): maps a provider
|
|
555
|
+
* location to an endpoint-relative route when it safely resolves inside the
|
|
556
|
+
* Service folder to an existing regular file. */
|
|
557
|
+
export function endpointRelativePathOf(providerLocation: string, endpointFolder: string): string | undefined {
|
|
558
|
+
if (!providerLocation.startsWith("qmd://")) return undefined;
|
|
559
|
+
const rest = providerLocation.slice("qmd://".length);
|
|
560
|
+
let candidate: string;
|
|
561
|
+
if (isAbsoluteShapedPath(rest)) {
|
|
562
|
+
const rel = relative(resolve(endpointFolder), resolve(rest));
|
|
563
|
+
// Cross-drive / UNC↔drive targets: `relative()` returns the absolute
|
|
564
|
+
// target itself, which never starts with `..` — reject explicitly so the
|
|
565
|
+
// containment boundary cannot be bypassed (ADR 0019 emission rule).
|
|
566
|
+
if (isAbsoluteShapedPath(rel)) return undefined;
|
|
567
|
+
candidate = rel.split(/[\\/]/).join("/");
|
|
568
|
+
} else {
|
|
569
|
+
const firstSlash = rest.indexOf("/");
|
|
570
|
+
if (firstSlash <= 0) return undefined;
|
|
571
|
+
candidate = rest.slice(firstSlash + 1).split(/[\\/]/).join("/");
|
|
572
|
+
}
|
|
573
|
+
if (candidate === "" || candidate.split("/").includes("..")) return undefined;
|
|
574
|
+
const absolute = resolve(endpointFolder, ...candidate.split("/"));
|
|
575
|
+
if (!isInsideRealRoot(resolve(endpointFolder), absolute)) return undefined;
|
|
576
|
+
// Resolved containment, aligned with the read hit path (G5 defect closure):
|
|
577
|
+
// a folder-internal symlink escaping the Service folder passes the lexical
|
|
578
|
+
// checks above but must not yield a URI the exact slot route would refuse.
|
|
579
|
+
let resolved: string;
|
|
580
|
+
let resolvedFolder: string;
|
|
581
|
+
try {
|
|
582
|
+
resolved = realpathSync(absolute);
|
|
583
|
+
resolvedFolder = realpathSync(endpointFolder);
|
|
584
|
+
} catch {
|
|
585
|
+
return undefined;
|
|
586
|
+
}
|
|
587
|
+
if (!isInsideRealRoot(resolvedFolder, resolved)) return undefined;
|
|
588
|
+
let stat;
|
|
589
|
+
try {
|
|
590
|
+
stat = statSync(resolved);
|
|
591
|
+
} catch {
|
|
592
|
+
return undefined;
|
|
593
|
+
}
|
|
594
|
+
if (!stat.isFile()) return undefined;
|
|
595
|
+
return candidate;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function ukpUriOf(providerLocation: string, endpointName: string, endpointFolder: string): string | undefined {
|
|
599
|
+
const relPath = endpointRelativePathOf(providerLocation, endpointFolder);
|
|
600
|
+
if (!relPath) return undefined;
|
|
601
|
+
return `ukp://${endpointName}/${relPath.split("/").map(encodeUkpUriSegment).join("/")}`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Map one QMD search result to a UKP read-ready reference (ADR 0011).
|
|
606
|
+
*
|
|
607
|
+
* QMD-indexed results carry a stable `docid` content fingerprint. `search`
|
|
608
|
+
* emits it as a bare handoff key (`#` stripped), not the weak `qmd://`/path
|
|
609
|
+
* name; `read` re-adds the `#` and resolves by fingerprint exactly. Name, title,
|
|
610
|
+
* and path become display-only provenance (`provider_location`). A result with
|
|
611
|
+
* no usable docid has no UKP read route and is `provider_only`.
|
|
612
|
+
*/
|
|
613
|
+
function mapQmdResultToReference(
|
|
614
|
+
endpointName: string,
|
|
615
|
+
endpointFolder: string,
|
|
616
|
+
result: unknown,
|
|
617
|
+
): QmdReferenceMapping {
|
|
618
|
+
const providerLocation = providerLocationOf(result);
|
|
619
|
+
const ukpUri = ukpUriOf(providerLocation, endpointName, endpointFolder);
|
|
620
|
+
const docid = docidOf(result);
|
|
621
|
+
if (!docid) {
|
|
622
|
+
return {
|
|
623
|
+
provider_location: providerLocation,
|
|
624
|
+
endpoint: endpointName,
|
|
625
|
+
status: "provider_only",
|
|
626
|
+
...(ukpUri ? { ukp_uri: ukpUri } : {}),
|
|
627
|
+
reason: "qmd result has no usable docid",
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
provider_location: providerLocation,
|
|
632
|
+
endpoint: endpointName,
|
|
633
|
+
reference: docid,
|
|
634
|
+
...maybeLine(lineOf(result)),
|
|
635
|
+
status: "read_ready",
|
|
636
|
+
read_adapter: "qmd",
|
|
637
|
+
...(ukpUri ? { ukp_uri: ukpUri } : {}),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function rawTitleOf(result: unknown): string {
|
|
642
|
+
if (typeof result !== "object" || result === null) return "";
|
|
643
|
+
const title = (result as { title?: unknown }).title;
|
|
644
|
+
return typeof title === "string" && title.trim() !== "" ? title : "";
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function basenameOf(location: string): string {
|
|
648
|
+
if (!location) return "";
|
|
649
|
+
return location.split(/[\\/]/).filter(Boolean).pop() ?? location;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function snippetOf(result: unknown): string | undefined {
|
|
653
|
+
if (typeof result !== "object" || result === null) return undefined;
|
|
654
|
+
const snippet = (result as { snippet?: unknown }).snippet;
|
|
655
|
+
if (typeof snippet !== "string") return undefined;
|
|
656
|
+
const trimmed = snippet.trim();
|
|
657
|
+
return trimmed === "" ? undefined : trimmed;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* A snippet anchored at file line 1 that starts with a `---` frontmatter banner
|
|
662
|
+
* or a heading is file-head boilerplate, not a hit-location excerpt. It is not
|
|
663
|
+
* shown as the excerpt; the title stands in for it.
|
|
664
|
+
*/
|
|
665
|
+
function isFileHeadBanner(snippet: string, line: number | undefined): boolean {
|
|
666
|
+
if (line !== 1) return false;
|
|
667
|
+
const head = snippet.trimStart();
|
|
668
|
+
return head.startsWith("---") || head.startsWith("#");
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Render one structured QMD result as a result unit.
|
|
673
|
+
*
|
|
674
|
+
* A result unit answers three reader questions: what it is (`title — basename:line`),
|
|
675
|
+
* why it matches (excerpt), and how to read it (a copyable `read` line addressing
|
|
676
|
+
* the ADR 0011 handoff key `docid[:line]`). Provider vocabulary never reaches the
|
|
677
|
+
* default Human surface: the docid appears only inside the `get` command, and
|
|
678
|
+
* location provenance is reduced to a human-readable basename (the raw `qmd://`
|
|
679
|
+
* stays in the `--json` reference sidecar). A result with no usable docid has no
|
|
680
|
+
* get route and says so in plain words.
|
|
681
|
+
*/
|
|
682
|
+
function renderResultUnit(
|
|
683
|
+
unitIndex: number,
|
|
684
|
+
endpointName: string,
|
|
685
|
+
endpointFolder: string,
|
|
686
|
+
result: unknown,
|
|
687
|
+
remoteUri?: string,
|
|
688
|
+
remoteEndpoint = false,
|
|
689
|
+
): string {
|
|
690
|
+
const providerLocation = providerLocationOf(result);
|
|
691
|
+
const docid = docidOf(result);
|
|
692
|
+
const line = lineOf(result);
|
|
693
|
+
// Remote endpoints carry the server-declared ukp_uri (RQ-06/RQ-09): it is
|
|
694
|
+
// the handoff key, so the read line addresses the URI directly and the
|
|
695
|
+
// local docid serves provenance only.
|
|
696
|
+
const ukpUri = remoteUri ?? ukpUriOf(providerLocation, endpointName, endpointFolder);
|
|
697
|
+
const title = rawTitleOf(result);
|
|
698
|
+
const base = basenameOf(providerLocation);
|
|
699
|
+
const location = base ? (line ? `${base}:${line}` : base) : "";
|
|
700
|
+
const identity = title
|
|
701
|
+
? (location ? `${title} — ${location}` : title)
|
|
702
|
+
: (location || "");
|
|
703
|
+
|
|
704
|
+
const snippet = snippetOf(result);
|
|
705
|
+
const fallback = title || base;
|
|
706
|
+
const excerpt = snippet && !isFileHeadBanner(snippet, line) ? snippet : fallback;
|
|
707
|
+
|
|
708
|
+
const lines = [`${unitIndex}. ${identity}`];
|
|
709
|
+
if (excerpt && excerpt !== fallback) lines.push(` ${excerpt.replace(/\n/g, "\n ")}`);
|
|
710
|
+
if (remoteUri !== undefined) {
|
|
711
|
+
lines.push(` read: ukp read ${remoteUri}${line ? `#L${line}` : ""}`);
|
|
712
|
+
} else if (docid && !remoteEndpoint) {
|
|
713
|
+
// Remote endpoints never hand off via docid (RQ-09: session-scoped
|
|
714
|
+
// fingerprint, meaningless across the wire) — a result without a
|
|
715
|
+
// server-declared ukp_uri has no remote read route at all.
|
|
716
|
+
const key = line ? `${docid}:${line}` : docid;
|
|
717
|
+
lines.push(` read: ukp read --endpoint ${endpointName} ${key}`);
|
|
718
|
+
} else {
|
|
719
|
+
lines.push(` (no direct read — provider-managed result)`);
|
|
720
|
+
}
|
|
721
|
+
if (ukpUri && remoteUri === undefined) lines.push(` uri: ${ukpUri}`);
|
|
722
|
+
return lines.join("\n");
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Render an endpoint's structured QMD results as numbered result units.
|
|
727
|
+
*
|
|
728
|
+
* Returns `null` when the provider stdout is not a JSON array (or cannot be
|
|
729
|
+
* parsed), so the caller falls back to the appended raw-provider format. An
|
|
730
|
+
* empty array renders `(no matches)`. Units are separated by a blank line so
|
|
731
|
+
* each `N.` block reads as one self-contained chunk even with multi-line excerpts.
|
|
732
|
+
*/
|
|
733
|
+
function renderResultUnits(
|
|
734
|
+
providerOutput: string,
|
|
735
|
+
endpointName: string,
|
|
736
|
+
endpointFolder: string,
|
|
737
|
+
remoteUris?: ReadonlyArray<string | undefined>,
|
|
738
|
+
): string | null {
|
|
739
|
+
let nativeResults: unknown;
|
|
740
|
+
try {
|
|
741
|
+
nativeResults = JSON.parse(providerOutput);
|
|
742
|
+
} catch {
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
if (!Array.isArray(nativeResults)) return null;
|
|
746
|
+
if (nativeResults.length === 0) return "(no matches)";
|
|
747
|
+
return nativeResults
|
|
748
|
+
.map((result, index) => renderResultUnit(index + 1, endpointName, endpointFolder, result, remoteUris?.[index], remoteUris !== undefined))
|
|
749
|
+
.join("\n\n");
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Fallback (spec "Fallback"): a provider that did not return a parseable JSON
|
|
754
|
+
* array renders as its raw output block plus a trailing `UKP reference:` list
|
|
755
|
+
* (docid form). Docids are read from the provider's QMD location header lines
|
|
756
|
+
* (`qmd://...:line #docid`); body lines with a 6-hex token (e.g. a color code)
|
|
757
|
+
* are never treated as docids.
|
|
758
|
+
*/
|
|
759
|
+
function renderFallbackProviderBlock(providerOutput: string, endpointName: string): string {
|
|
760
|
+
const lines = [providerOutput];
|
|
761
|
+
const seen = new Set<string>();
|
|
762
|
+
for (const textLine of providerOutput.split(/\r?\n/)) {
|
|
763
|
+
if (!textLine.startsWith("qmd://")) continue;
|
|
764
|
+
const docid = /#([a-f0-9]{6})/.exec(textLine);
|
|
765
|
+
if (!docid) continue;
|
|
766
|
+
const bare = docid[1];
|
|
767
|
+
if (!isDocidBody(bare) || seen.has(bare)) continue;
|
|
768
|
+
seen.add(bare);
|
|
769
|
+
const lineHit = /:(\d+)\s+#[a-f0-9]{6}/.exec(textLine);
|
|
770
|
+
const lineHint = lineHit ? ` --lines ${Number(lineHit[1])}` : "";
|
|
771
|
+
lines.push(`UKP reference: ukp read --endpoint ${endpointName} ${bare}${lineHint}`);
|
|
772
|
+
}
|
|
773
|
+
return lines.join("\n");
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function buildQmdReferenceSidecar(
|
|
777
|
+
endpointName: string,
|
|
778
|
+
endpointFolder: string,
|
|
779
|
+
sourceArtifact: string,
|
|
780
|
+
): QmdReferenceSidecar {
|
|
781
|
+
if (statSync(sourceArtifact).size > 1024 * 1024) {
|
|
782
|
+
return {
|
|
783
|
+
schema: "ukp.search.references.v1",
|
|
784
|
+
endpoint: endpointName,
|
|
785
|
+
source_artifact: sourceArtifact,
|
|
786
|
+
results: [],
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
const nativeResults = JSON.parse(readFileSync(sourceArtifact, "utf8"));
|
|
790
|
+
if (!Array.isArray(nativeResults)) {
|
|
791
|
+
throw new Error("qmd-json output is not an array");
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
schema: "ukp.search.references.v1",
|
|
795
|
+
endpoint: endpointName,
|
|
796
|
+
source_artifact: sourceArtifact,
|
|
797
|
+
results: nativeResults.map((result, index) => ({
|
|
798
|
+
index,
|
|
799
|
+
...mapQmdResultToReference(endpointName, endpointFolder, result),
|
|
800
|
+
})),
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function writeQmdReferenceSidecar(
|
|
805
|
+
endpointName: string,
|
|
806
|
+
endpointFolder: string,
|
|
807
|
+
sourceArtifact: string,
|
|
808
|
+
sidecarPath: string,
|
|
809
|
+
): void {
|
|
810
|
+
const sidecar = buildQmdReferenceSidecar(endpointName, endpointFolder, sourceArtifact);
|
|
811
|
+
writeFileSync(sidecarPath, `${JSON.stringify(sidecar, null, 2)}\n`, {
|
|
812
|
+
encoding: "utf8",
|
|
813
|
+
mode: 0o600,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Inline reference payload for artifact-less surfaces (ukp_remote serve,
|
|
818
|
+
* RQ-07: remote responses inline the reference data — the serving side holds
|
|
819
|
+
* no artifact root). Same mapping as the sidecar; `source_artifact` is
|
|
820
|
+
* omitted because there is no artifact. Undefined when the provider output is
|
|
821
|
+
* not a parseable JSON array, matching the sidecar's fallback semantics. */
|
|
822
|
+
export interface InlineSearchReferences {
|
|
823
|
+
schema: "ukp.search.references.v1";
|
|
824
|
+
endpoint: string;
|
|
825
|
+
results: Array<QmdReferenceMapping & { index: number }>;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
export function buildInlineReferences(
|
|
829
|
+
endpointName: string,
|
|
830
|
+
endpointFolder: string,
|
|
831
|
+
providerOutput: string,
|
|
832
|
+
): InlineSearchReferences | undefined {
|
|
833
|
+
let nativeResults: unknown;
|
|
834
|
+
try {
|
|
835
|
+
nativeResults = JSON.parse(providerOutput);
|
|
836
|
+
} catch {
|
|
837
|
+
return undefined;
|
|
838
|
+
}
|
|
839
|
+
if (!Array.isArray(nativeResults)) return undefined;
|
|
840
|
+
return {
|
|
841
|
+
schema: "ukp.search.references.v1",
|
|
842
|
+
endpoint: endpointName,
|
|
843
|
+
results: nativeResults.map((result, index) => ({
|
|
844
|
+
index,
|
|
845
|
+
...mapQmdResultToReference(endpointName, endpointFolder, result),
|
|
846
|
+
})),
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function runJsonMode(
|
|
851
|
+
parsed: ParsedSearch,
|
|
852
|
+
context: SearchContext,
|
|
853
|
+
plan: readonly PlannedEndpoint[],
|
|
854
|
+
warnings: string[],
|
|
855
|
+
): SearchResult {
|
|
856
|
+
const run = createArtifactRun({
|
|
857
|
+
root: context.artifactRoot,
|
|
858
|
+
runId: context.artifactRunId,
|
|
859
|
+
now: context.now,
|
|
860
|
+
});
|
|
861
|
+
let failed = false;
|
|
862
|
+
let succeeded = false;
|
|
863
|
+
let cancelled = false;
|
|
864
|
+
|
|
865
|
+
const endpoints: SearchEndpointOutcome[] = [];
|
|
866
|
+
for (const endpoint of plan) {
|
|
867
|
+
if (endpoint.status === "skipped") {
|
|
868
|
+
endpoints.push({
|
|
869
|
+
name: endpoint.name,
|
|
870
|
+
provider: endpoint.provider,
|
|
871
|
+
status: "skipped",
|
|
872
|
+
message: endpoint.warning,
|
|
873
|
+
...(endpoint.traversal ? { traversal: endpoint.traversal } : {}),
|
|
874
|
+
});
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
if (cancelled) {
|
|
878
|
+
endpoints.push({
|
|
879
|
+
name: endpoint.name,
|
|
880
|
+
provider: endpoint.provider,
|
|
881
|
+
status: "cancelled",
|
|
882
|
+
...(endpoint.traversal ? { traversal: endpoint.traversal } : {}),
|
|
883
|
+
});
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const artifact = resolve(join(run.directory, `${endpoint.name}.json`));
|
|
888
|
+
const referencesArtifact = resolve(join(run.directory, `${endpoint.name}.references.json`));
|
|
889
|
+
const errorArtifact = resolve(join(run.directory, `${endpoint.name}.stderr.txt`));
|
|
890
|
+
let stdoutFd: number | undefined;
|
|
891
|
+
let stderrFd: number | undefined;
|
|
892
|
+
const outcome: SearchEndpointOutcome = {
|
|
893
|
+
name: endpoint.name,
|
|
894
|
+
provider: endpoint.provider,
|
|
895
|
+
...(endpoint.traversal ? { traversal: endpoint.traversal } : {}),
|
|
896
|
+
folder: endpoint.folder,
|
|
897
|
+
};
|
|
898
|
+
endpoints.push(outcome);
|
|
899
|
+
try {
|
|
900
|
+
stdoutFd = openSync(artifact, "w", 0o600);
|
|
901
|
+
stderrFd = openSync(errorArtifact, "w", 0o600);
|
|
902
|
+
const command = commandFor(endpoint, parsed, true);
|
|
903
|
+
const result = spawnSync(command.file, command.args, {
|
|
904
|
+
cwd: endpoint.folder!,
|
|
905
|
+
stdio: ["ignore", stdoutFd, stderrFd],
|
|
906
|
+
windowsHide: true,
|
|
907
|
+
windowsVerbatimArguments: command.verbatim,
|
|
908
|
+
timeout: providerTimeoutMs(),
|
|
909
|
+
});
|
|
910
|
+
closeSync(stdoutFd);
|
|
911
|
+
stdoutFd = undefined;
|
|
912
|
+
closeSync(stderrFd);
|
|
913
|
+
stderrFd = undefined;
|
|
914
|
+
|
|
915
|
+
outcome.artifact = artifact;
|
|
916
|
+
outcome.format = "qmd-json";
|
|
917
|
+
|
|
918
|
+
if (result.signal === "SIGTERM") {
|
|
919
|
+
// Zero-output hang guard: classified failure + error artifact, never
|
|
920
|
+
// silence (see qmd.ts providerTimeoutMs).
|
|
921
|
+
failed = true;
|
|
922
|
+
appendErrorArtifact(errorArtifact, `provider timed out after ${providerTimeoutMs() / 1000}s`);
|
|
923
|
+
const message = `endpoint '${endpoint.name}' provider timed out`;
|
|
924
|
+
warnings.push(message);
|
|
925
|
+
outcome.status = "failed";
|
|
926
|
+
outcome.error_artifact = errorArtifact;
|
|
927
|
+
outcome.message = message;
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (result.signal === "SIGINT") {
|
|
932
|
+
cancelled = true;
|
|
933
|
+
appendErrorArtifact(errorArtifact, "provider cancelled by SIGINT");
|
|
934
|
+
outcome.status = "cancelled";
|
|
935
|
+
outcome.error_artifact = errorArtifact;
|
|
936
|
+
outcome.message = "provider cancelled by SIGINT";
|
|
937
|
+
warnings.push(`endpoint '${endpoint.name}' provider cancelled`);
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (result.status !== 0 || result.error) {
|
|
942
|
+
failed = true;
|
|
943
|
+
if (result.error) appendErrorArtifact(errorArtifact, result.error.message);
|
|
944
|
+
const message = `endpoint '${endpoint.name}' provider failed`;
|
|
945
|
+
warnings.push(message);
|
|
946
|
+
outcome.status = "failed";
|
|
947
|
+
outcome.error_artifact = errorArtifact;
|
|
948
|
+
outcome.message = message;
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
let status: "succeeded" | "no_matches";
|
|
953
|
+
try {
|
|
954
|
+
status = classifyQmdArtifact(artifact);
|
|
955
|
+
} catch (error) {
|
|
956
|
+
failed = true;
|
|
957
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
958
|
+
appendErrorArtifact(errorArtifact, `invalid qmd-json output: ${detail}`);
|
|
959
|
+
const message = `endpoint '${endpoint.name}' returned invalid qmd-json`;
|
|
960
|
+
warnings.push(message);
|
|
961
|
+
outcome.status = "failed";
|
|
962
|
+
outcome.error_artifact = errorArtifact;
|
|
963
|
+
outcome.message = message;
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
succeeded = true;
|
|
968
|
+
outcome.status = status;
|
|
969
|
+
let hasReferenceSidecar = false;
|
|
970
|
+
try {
|
|
971
|
+
writeQmdReferenceSidecar(endpoint.name, endpoint.folder!, artifact, referencesArtifact);
|
|
972
|
+
hasReferenceSidecar = true;
|
|
973
|
+
} catch (error) {
|
|
974
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
975
|
+
appendErrorArtifact(errorArtifact, `invalid qmd-json reference mapping: ${detail}`);
|
|
976
|
+
warnings.push(`endpoint '${endpoint.name}' reference sidecar unavailable: ${detail}`);
|
|
977
|
+
}
|
|
978
|
+
if (hasReferenceSidecar) {
|
|
979
|
+
outcome.references_artifact = referencesArtifact;
|
|
980
|
+
outcome.references_format = "ukp-search-references-v1";
|
|
981
|
+
}
|
|
982
|
+
const hasProviderStderr = statSync(errorArtifact).size > 0;
|
|
983
|
+
if (!hasProviderStderr) unlinkSync(errorArtifact);
|
|
984
|
+
else outcome.error_artifact = errorArtifact;
|
|
985
|
+
} catch (error) {
|
|
986
|
+
failed = true;
|
|
987
|
+
if (stdoutFd !== undefined) closeSync(stdoutFd);
|
|
988
|
+
if (stderrFd !== undefined) closeSync(stderrFd);
|
|
989
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
990
|
+
try {
|
|
991
|
+
appendErrorArtifact(errorArtifact, detail);
|
|
992
|
+
} catch {
|
|
993
|
+
// The envelope still records the failure if even the error artifact cannot be written.
|
|
994
|
+
}
|
|
995
|
+
const message = `endpoint '${endpoint.name}' artifact or provider execution failed: ${detail}`;
|
|
996
|
+
warnings.push(message);
|
|
997
|
+
outcome.status = "failed";
|
|
998
|
+
if (!existsSync(artifact)) {
|
|
999
|
+
delete outcome.artifact;
|
|
1000
|
+
delete outcome.format;
|
|
1001
|
+
}
|
|
1002
|
+
if (existsSync(errorArtifact)) outcome.error_artifact = errorArtifact;
|
|
1003
|
+
outcome.message = message;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
return {
|
|
1008
|
+
query: parsed.request.query,
|
|
1009
|
+
limit: parsed.request.limit,
|
|
1010
|
+
runId: run.runId,
|
|
1011
|
+
endpoints,
|
|
1012
|
+
warnings,
|
|
1013
|
+
aggregate: cancelled
|
|
1014
|
+
? "cancelled"
|
|
1015
|
+
: failed
|
|
1016
|
+
? "provider-failure"
|
|
1017
|
+
: succeeded
|
|
1018
|
+
? "succeeded"
|
|
1019
|
+
: "no-success",
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** ADR 0021 core entry: runs the search capability and returns the
|
|
1024
|
+
* structured outcome. Scope/planning failures still throw typed errors
|
|
1025
|
+
* (`SearchPlanningError`, `ScopeError`, `ManifestError`)
|
|
1026
|
+
* for the surface adapter to map. */
|
|
1027
|
+
export function runSearch(parsed: ParsedSearch, context: SearchContext): SearchResult {
|
|
1028
|
+
const { plan, warnings } = planSearch(parsed, context);
|
|
1029
|
+
return parsed.options.json
|
|
1030
|
+
? runJsonMode(parsed, context, plan, warnings)
|
|
1031
|
+
: runHumanMode(parsed, plan, warnings);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// ---------------------------------------------------------------------------
|
|
1035
|
+
// Presentation (ADR 0021 two-stage form): project a structured view first,
|
|
1036
|
+
// render text from the view only. Adapters must consume these — never build
|
|
1037
|
+
// a private rendering pipeline (single render source).
|
|
1038
|
+
// ---------------------------------------------------------------------------
|
|
1039
|
+
|
|
1040
|
+
/** Projection onto the public `ukp.search.v1` envelope: contract-zone fields
|
|
1041
|
+
* in the canonical order; internal provenance (`folder`, raw provider
|
|
1042
|
+
* output/exit) stays out. `interrupted` never occurs in json mode; it maps
|
|
1043
|
+
* to `cancelled` defensively. */
|
|
1044
|
+
function toEndpointEnvelope(outcome: SearchEndpointOutcome): SearchEndpointEnvelope {
|
|
1045
|
+
const status = outcome.status ?? "failed";
|
|
1046
|
+
return {
|
|
1047
|
+
name: outcome.name,
|
|
1048
|
+
provider: outcome.provider,
|
|
1049
|
+
status: status === "interrupted" ? "cancelled" : status,
|
|
1050
|
+
...(outcome.artifact !== undefined ? { artifact: outcome.artifact } : {}),
|
|
1051
|
+
...(outcome.format !== undefined ? { format: outcome.format } : {}),
|
|
1052
|
+
...(outcome.references_artifact !== undefined ? { references_artifact: outcome.references_artifact } : {}),
|
|
1053
|
+
...(outcome.references_format !== undefined ? { references_format: outcome.references_format } : {}),
|
|
1054
|
+
...(outcome.error_artifact !== undefined ? { error_artifact: outcome.error_artifact } : {}),
|
|
1055
|
+
...(outcome.message !== undefined ? { message: outcome.message } : {}),
|
|
1056
|
+
...(outcome.traversal ?? {}),
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
export function projectSearchEnvelope(result: SearchResult): SearchEnvelope {
|
|
1061
|
+
return {
|
|
1062
|
+
schema: "ukp.search.v1",
|
|
1063
|
+
run_id: result.runId ?? "",
|
|
1064
|
+
command: "search",
|
|
1065
|
+
capability: "search",
|
|
1066
|
+
query: result.query,
|
|
1067
|
+
limit: result.limit,
|
|
1068
|
+
endpoints: result.endpoints.map(toEndpointEnvelope),
|
|
1069
|
+
warnings: result.warnings,
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
export function renderSearchJson(envelope: SearchEnvelope): string {
|
|
1074
|
+
return `${JSON.stringify(envelope, null, 2)}\n`;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/** Human view: `== <name> ==` blocks in plan order. Skipped endpoints live
|
|
1078
|
+
* only in warnings; the first `interrupted` endpoint keeps its header (the
|
|
1079
|
+
* SIGINT stop happened mid-block) and ends the output; post-interrupt
|
|
1080
|
+
* `cancelled` endpoints render nothing. */
|
|
1081
|
+
export function renderSearchHuman(result: SearchResult): string {
|
|
1082
|
+
const lines: string[] = [];
|
|
1083
|
+
for (const endpoint of result.endpoints) {
|
|
1084
|
+
if (endpoint.status === "skipped") continue;
|
|
1085
|
+
if (endpoint.status === "cancelled") break;
|
|
1086
|
+
if (lines.length > 0) lines.push("");
|
|
1087
|
+
lines.push(`== ${endpoint.name} ==`);
|
|
1088
|
+
if (endpoint.traversal) lines.push(...renderTraversalProvenance(endpoint.traversal));
|
|
1089
|
+
if (endpoint.status === "interrupted") break;
|
|
1090
|
+
if (endpoint.providerOutput) {
|
|
1091
|
+
lines.push(
|
|
1092
|
+
endpoint.folder
|
|
1093
|
+
? (renderResultUnits(endpoint.providerOutput, endpoint.name, endpoint.folder)
|
|
1094
|
+
?? renderFallbackProviderBlock(endpoint.providerOutput, endpoint.name))
|
|
1095
|
+
: endpoint.remoteUris !== undefined
|
|
1096
|
+
? (renderResultUnits(endpoint.providerOutput, endpoint.name, "", endpoint.remoteUris)
|
|
1097
|
+
?? renderFallbackProviderBlock(endpoint.providerOutput, endpoint.name))
|
|
1098
|
+
: renderFallbackProviderBlock(endpoint.providerOutput, endpoint.name),
|
|
1099
|
+
);
|
|
1100
|
+
} else if (endpoint.providerExitStatus === 0) {
|
|
1101
|
+
lines.push("(no matches)");
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
return lines.length > 0 ? `${lines.join("\n")}\n` : "";
|
|
1105
|
+
}
|