@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,87 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { hostname, networkInterfaces } from "node:os";
|
|
4
|
+
import { X509Certificate, createHash } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
/** TLS transport identity (ukp_remote W5' / D-079): the self-signed + TOFU
|
|
7
|
+
* pinning path for serving over bare IPs without a public CA. Shared by the
|
|
8
|
+
* serve side (identity generation, banner fingerprint) and the client side
|
|
9
|
+
* (registration-time pinning, renewal re-anchor). The identity convention is
|
|
10
|
+
* the RFC 7469 SPKI pin — `sha256/<base64 of the DER SubjectPublicKeyInfo>`
|
|
11
|
+
* — so a certificate renewal that keeps the key keeps the identity. */
|
|
12
|
+
|
|
13
|
+
export function spkiPinOf(cert: string | Buffer): string {
|
|
14
|
+
const x509 = new X509Certificate(cert);
|
|
15
|
+
const spkiDer = x509.publicKey.export({ type: "spki", format: "der" });
|
|
16
|
+
return `sha256/${createHash("sha256").update(spkiDer).digest("base64")}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** SAN list for display (X509Certificate.subjectAltName form, e.g.
|
|
20
|
+
* "IP Address:127.0.0.1, DNS:localhost"). */
|
|
21
|
+
export function certSanOf(cert: string | Buffer): string {
|
|
22
|
+
return new X509Certificate(cert).subjectAltName ?? "(no subjectAltName)";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** SAN coverage for a self-signed identity: every non-internal interface
|
|
26
|
+
* address plus loopback and the hostname — a bare-IP deployment is reachable
|
|
27
|
+
* under any of them, and clients verify the url host against the SAN. */
|
|
28
|
+
export function selfSignedSanEntries(): string[] {
|
|
29
|
+
const entries = new Set<string>(["IP:127.0.0.1", "IP:::1", "DNS:localhost"]);
|
|
30
|
+
const host = hostname();
|
|
31
|
+
if (/^[A-Za-z0-9.-]+$/.test(host) && !host.includes("_")) entries.add(`DNS:${host}`);
|
|
32
|
+
for (const addresses of Object.values(networkInterfaces())) {
|
|
33
|
+
for (const address of addresses ?? []) {
|
|
34
|
+
if (!address.internal) entries.add(`IP:${address.address}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return [...entries];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SelfSignedIdentity {
|
|
41
|
+
certPath: string;
|
|
42
|
+
keyPath: string;
|
|
43
|
+
/** true when this call generated the pair; false when reusing the persisted one. */
|
|
44
|
+
created: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Idempotent self-signed identity for `ukp serve --tls`: persisted under
|
|
48
|
+
* `<serviceFolder>/.ukp/tls/` (machine-local, gitignored — the instance-uid
|
|
49
|
+
* posture). openssl signs it because node:crypto cannot issue X.509; the
|
|
50
|
+
* identity is the keypair, so losing the key loses the identity (clients
|
|
51
|
+
* re-register). `opensslCommand` is a test injection point. */
|
|
52
|
+
export function ensureSelfSignedTlsFiles(
|
|
53
|
+
tlsDir: string,
|
|
54
|
+
options: { opensslCommand?: readonly string[] } = {},
|
|
55
|
+
): SelfSignedIdentity {
|
|
56
|
+
const certPath = join(tlsDir, "cert.pem");
|
|
57
|
+
const keyPath = join(tlsDir, "key.pem");
|
|
58
|
+
if (existsSync(certPath) && existsSync(keyPath)) {
|
|
59
|
+
return { certPath, keyPath, created: false };
|
|
60
|
+
}
|
|
61
|
+
mkdirSync(tlsDir, { recursive: true, mode: 0o700 });
|
|
62
|
+
const proc = Bun.spawnSync(
|
|
63
|
+
[
|
|
64
|
+
...(options.opensslCommand ?? ["openssl"]),
|
|
65
|
+
"req", "-x509", "-newkey", "rsa:2048",
|
|
66
|
+
"-keyout", keyPath,
|
|
67
|
+
"-out", certPath,
|
|
68
|
+
"-days", "3650",
|
|
69
|
+
"-nodes",
|
|
70
|
+
"-subj", "/CN=ukp serve self-signed",
|
|
71
|
+
"-addext", `subjectAltName=${selfSignedSanEntries().join(",")}`,
|
|
72
|
+
],
|
|
73
|
+
{ stdout: "ignore", stderr: "pipe" },
|
|
74
|
+
);
|
|
75
|
+
if (proc.exitCode !== 0 || !existsSync(certPath) || !existsSync(keyPath)) {
|
|
76
|
+
const stderr = new TextDecoder().decode(proc.stderr).trim();
|
|
77
|
+
throw new Error(
|
|
78
|
+
`unable to generate a self-signed TLS identity with openssl (exit ${proc.exitCode}): ${stderr || "is openssl installed and on PATH? 'ukp serve --tls' self-signs through the local openssl"}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
chmodSync(keyPath, 0o600);
|
|
83
|
+
} catch {
|
|
84
|
+
// best-effort on platforms without POSIX modes
|
|
85
|
+
}
|
|
86
|
+
return { certPath, keyPath, created: true };
|
|
87
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { loadClientConfig, findNearestClientConfig } from "../config/client.ts";
|
|
3
|
+
import { loadManifest } from "../config/manifest.ts";
|
|
4
|
+
import { isRemoteBinding, localPathOf, readRegistry, type RegistryBinding } from "../registry.ts";
|
|
5
|
+
import { ScopeError } from "../scope.ts";
|
|
6
|
+
import { buildQmdInvocation, defaultQmdCommand, updateTimeoutMs } from "./qmd.ts";
|
|
7
|
+
|
|
8
|
+
export interface UpdateOptions {
|
|
9
|
+
explicitEndpoints?: string[];
|
|
10
|
+
global: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ParsedUpdate {
|
|
14
|
+
options: UpdateOptions;
|
|
15
|
+
warnings: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UpdateContext {
|
|
19
|
+
currentDirectory: string;
|
|
20
|
+
registryPath: string;
|
|
21
|
+
qmdCommand?: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** ADR 0021 aggregate classification — the adapter maps it onto exit codes;
|
|
25
|
+
* the capability never decides them. */
|
|
26
|
+
export type UpdateAggregateStatus =
|
|
27
|
+
| "succeeded"
|
|
28
|
+
| "failed"
|
|
29
|
+
| "cancelled"
|
|
30
|
+
| "no-success";
|
|
31
|
+
|
|
32
|
+
/** One endpoint's structured update outcome. `message` keeps today's
|
|
33
|
+
* wording (including Hint lines — same documented debt class as search
|
|
34
|
+
* warnings); `providerOutput` is the provider-native zone, passed through
|
|
35
|
+
* unmodeled. */
|
|
36
|
+
export interface UpdateEndpointOutcome {
|
|
37
|
+
name: string;
|
|
38
|
+
provider: string | null;
|
|
39
|
+
/** Transiently undefined while the provider run classifies the entry;
|
|
40
|
+
* every path assigns a final status before the outcome escapes. */
|
|
41
|
+
status?:
|
|
42
|
+
| "updated"
|
|
43
|
+
| "skipped"
|
|
44
|
+
/** plan-stage failure (name mismatch, manifest error) — renders with an
|
|
45
|
+
* `error:` line, unlike a provider failure. */
|
|
46
|
+
| "plan-failed"
|
|
47
|
+
| "provider-failed"
|
|
48
|
+
| "timeout"
|
|
49
|
+
/** this endpoint's SIGINT stopped the run — its header block was already
|
|
50
|
+
* rendered through `status: cancelled`, later endpoints render nothing. */
|
|
51
|
+
| "interrupted"
|
|
52
|
+
/** not reached because an earlier endpoint was interrupted. */
|
|
53
|
+
| "cancelled";
|
|
54
|
+
message?: string;
|
|
55
|
+
providerOutput?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** ADR 0021 structured outcome. */
|
|
59
|
+
export interface UpdateOutcome {
|
|
60
|
+
endpoints: UpdateEndpointOutcome[];
|
|
61
|
+
warnings: string[];
|
|
62
|
+
aggregate: UpdateAggregateStatus;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class UpdateUsageError extends Error {
|
|
66
|
+
constructor(message: string) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = "UpdateUsageError";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type PlannedEndpoint =
|
|
73
|
+
| {
|
|
74
|
+
name: string;
|
|
75
|
+
provider: "qmd";
|
|
76
|
+
status: "executable";
|
|
77
|
+
folder: string;
|
|
78
|
+
command: readonly string[];
|
|
79
|
+
}
|
|
80
|
+
| {
|
|
81
|
+
name: string;
|
|
82
|
+
provider: string | null;
|
|
83
|
+
status: "skipped";
|
|
84
|
+
message: string;
|
|
85
|
+
}
|
|
86
|
+
| {
|
|
87
|
+
name: string;
|
|
88
|
+
provider: string | null;
|
|
89
|
+
status: "failed";
|
|
90
|
+
message: string;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function resolveUpdateBindings(
|
|
94
|
+
parsed: ParsedUpdate,
|
|
95
|
+
registry: readonly RegistryBinding[],
|
|
96
|
+
context: UpdateContext,
|
|
97
|
+
): { bindings: RegistryBinding[]; warnings: string[] } {
|
|
98
|
+
if (parsed.options.global && parsed.options.explicitEndpoints !== undefined) {
|
|
99
|
+
throw new ScopeError("explicit endpoint scope and global scope cannot be used together");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const warnings = [...parsed.warnings];
|
|
103
|
+
const byName = new Map(registry.map((binding) => [binding.name, binding]));
|
|
104
|
+
|
|
105
|
+
if (parsed.options.explicitEndpoints !== undefined) {
|
|
106
|
+
return {
|
|
107
|
+
bindings: parsed.options.explicitEndpoints.map((name) => {
|
|
108
|
+
const binding = byName.get(name);
|
|
109
|
+
if (!binding) throw new ScopeError(`unknown endpoint '${name}'`);
|
|
110
|
+
return binding;
|
|
111
|
+
}),
|
|
112
|
+
warnings,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (parsed.options.global) return { bindings: [...registry], warnings };
|
|
117
|
+
|
|
118
|
+
const configPath = findNearestClientConfig(context.currentDirectory);
|
|
119
|
+
if (!configPath) {
|
|
120
|
+
throw new ScopeError("update requires an explicit scope; use '--endpoint <name>' or '-g'");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const config = loadClientConfig(configPath);
|
|
124
|
+
const bindings: RegistryBinding[] = [];
|
|
125
|
+
for (const name of config.default_endpoints) {
|
|
126
|
+
const binding = byName.get(name);
|
|
127
|
+
if (binding) bindings.push(binding);
|
|
128
|
+
else warnings.push(`'${name}' is not registered (from ${configPath})`);
|
|
129
|
+
}
|
|
130
|
+
if (bindings.length === 0) {
|
|
131
|
+
throw new ScopeError("Client Config does not resolve to any registered endpoint");
|
|
132
|
+
}
|
|
133
|
+
return { bindings, warnings };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function planUpdate(parsed: ParsedUpdate, context: UpdateContext): {
|
|
137
|
+
plan: PlannedEndpoint[];
|
|
138
|
+
warnings: string[];
|
|
139
|
+
} {
|
|
140
|
+
const registry = readRegistry(context.registryPath);
|
|
141
|
+
const { bindings, warnings } = resolveUpdateBindings(parsed, registry, context);
|
|
142
|
+
const qmdCommand = context.qmdCommand ?? defaultQmdCommand();
|
|
143
|
+
const plan: PlannedEndpoint[] = [];
|
|
144
|
+
|
|
145
|
+
if (bindings.length === 0) {
|
|
146
|
+
warnings.push("no endpoints selected: the Host Registry is empty; run 'ukp register' from a Service folder, then retry");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const binding of bindings) {
|
|
150
|
+
if (isRemoteBinding(binding)) {
|
|
151
|
+
plan.push({
|
|
152
|
+
name: binding.name,
|
|
153
|
+
provider: null,
|
|
154
|
+
status: "skipped",
|
|
155
|
+
message: `endpoint '${binding.name}' is remote (${binding.url}); update is not yet remote-enabled (ukp_remote W3)`,
|
|
156
|
+
});
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const service = loadManifest(localPathOf(binding));
|
|
161
|
+
if (service.effectiveName !== binding.name) {
|
|
162
|
+
plan.push({
|
|
163
|
+
name: binding.name,
|
|
164
|
+
provider: null,
|
|
165
|
+
status: "failed",
|
|
166
|
+
message: [
|
|
167
|
+
`endpoint '${binding.name}' no longer matches Service effective name '${service.effectiveName}'`,
|
|
168
|
+
`Hint: run 'ukp inspect --endpoint ${binding.name}' and re-register the Service if the binding is stale.`,
|
|
169
|
+
].join("\n"),
|
|
170
|
+
});
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const capability = service.manifest.capabilities.update;
|
|
175
|
+
if (!capability) {
|
|
176
|
+
plan.push({
|
|
177
|
+
name: binding.name,
|
|
178
|
+
provider: null,
|
|
179
|
+
status: "skipped",
|
|
180
|
+
message: [
|
|
181
|
+
`endpoint '${binding.name}' does not provide update`,
|
|
182
|
+
`Hint: run 'ukp inspect --endpoint ${binding.name}' to review Service capabilities.`,
|
|
183
|
+
].join("\n"),
|
|
184
|
+
});
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (capability.provider !== "qmd") {
|
|
189
|
+
const provider = capability.provider ?? "(none)";
|
|
190
|
+
plan.push({
|
|
191
|
+
name: binding.name,
|
|
192
|
+
provider,
|
|
193
|
+
status: "skipped",
|
|
194
|
+
message: `endpoint '${binding.name}' uses unsupported update provider '${provider}'`,
|
|
195
|
+
});
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!qmdCommand || qmdCommand.length === 0) {
|
|
200
|
+
plan.push({
|
|
201
|
+
name: binding.name,
|
|
202
|
+
provider: "qmd",
|
|
203
|
+
status: "skipped",
|
|
204
|
+
message: [
|
|
205
|
+
`endpoint '${binding.name}' update unavailable: qmd executable is not available`,
|
|
206
|
+
"Hint: install QMD or pass a valid qmd command in the execution context.",
|
|
207
|
+
].join("\n"),
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
plan.push({
|
|
213
|
+
name: binding.name,
|
|
214
|
+
provider: "qmd",
|
|
215
|
+
status: "executable",
|
|
216
|
+
folder: service.folder,
|
|
217
|
+
command: qmdCommand,
|
|
218
|
+
});
|
|
219
|
+
} catch (error) {
|
|
220
|
+
plan.push({
|
|
221
|
+
name: binding.name,
|
|
222
|
+
provider: null,
|
|
223
|
+
status: "failed",
|
|
224
|
+
message: [
|
|
225
|
+
error instanceof Error ? error.message : String(error),
|
|
226
|
+
`Hint: run 'ukp inspect --endpoint ${binding.name}' or 'ukp diagnose --endpoint ${binding.name}'.`,
|
|
227
|
+
].join("\n"),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!plan.some((endpoint) => endpoint.status === "executable")) {
|
|
233
|
+
warnings.push("no executable update endpoints: run 'ukp inspect' to review selected endpoint capabilities");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { plan, warnings };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function commandFor(endpoint: Extract<PlannedEndpoint, { status: "executable" }>): {
|
|
240
|
+
file: string;
|
|
241
|
+
args: string[];
|
|
242
|
+
verbatim: boolean;
|
|
243
|
+
} {
|
|
244
|
+
// Uniform provider plumbing: a cmd.exe shim wrapper must get
|
|
245
|
+
// the whole call as one cmd-escaped /c payload.
|
|
246
|
+
return buildQmdInvocation(endpoint.command, ["update"]);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const QMD_MAINTENANCE_SCOPE =
|
|
250
|
+
"provider-owned (qmd update in the Service folder; QMD decides which configured collections are maintained)";
|
|
251
|
+
|
|
252
|
+
/** ADR 0021 core entry: runs the update capability and returns the
|
|
253
|
+
* structured outcome. Scope failures throw typed errors (`ScopeError`,
|
|
254
|
+
* `UpdateUsageError`) for the surface adapter to map. */
|
|
255
|
+
export function runUpdate(parsed: ParsedUpdate, context: UpdateContext): UpdateOutcome {
|
|
256
|
+
const { plan, warnings } = planUpdate(parsed, context);
|
|
257
|
+
const endpoints: UpdateEndpointOutcome[] = [];
|
|
258
|
+
let failed = plan.some((endpoint) => endpoint.status === "failed");
|
|
259
|
+
let succeeded = false;
|
|
260
|
+
let interrupted = false;
|
|
261
|
+
|
|
262
|
+
for (const endpoint of plan) {
|
|
263
|
+
if (endpoint.status === "skipped" || endpoint.status === "failed") {
|
|
264
|
+
endpoints.push({
|
|
265
|
+
name: endpoint.name,
|
|
266
|
+
provider: endpoint.provider,
|
|
267
|
+
status: endpoint.status === "failed" ? "plan-failed" : "skipped",
|
|
268
|
+
message: endpoint.message,
|
|
269
|
+
});
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (interrupted) {
|
|
273
|
+
endpoints.push({ name: endpoint.name, provider: endpoint.provider, status: "cancelled" });
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const outcome: UpdateEndpointOutcome = {
|
|
278
|
+
name: endpoint.name,
|
|
279
|
+
provider: "qmd",
|
|
280
|
+
};
|
|
281
|
+
endpoints.push(outcome);
|
|
282
|
+
const command = commandFor(endpoint);
|
|
283
|
+
const result = spawnSync(command.file, command.args, {
|
|
284
|
+
cwd: endpoint.folder,
|
|
285
|
+
encoding: "utf8",
|
|
286
|
+
windowsHide: true,
|
|
287
|
+
windowsVerbatimArguments: command.verbatim,
|
|
288
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
289
|
+
timeout: updateTimeoutMs(),
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const providerOutput = (result.stdout ?? "").trimEnd();
|
|
293
|
+
const providerError = (result.stderr ?? "").trimEnd() || result.error?.message;
|
|
294
|
+
|
|
295
|
+
if (result.signal === "SIGTERM") {
|
|
296
|
+
// Zero-output hang guard: `qmd update` on a large corpus is slow but
|
|
297
|
+
// bounded; beyond the ceiling it is a classified failure, not silence.
|
|
298
|
+
failed = true;
|
|
299
|
+
outcome.status = "timeout";
|
|
300
|
+
warnings.push(
|
|
301
|
+
`endpoint '${endpoint.name}' provider timed out after ${updateTimeoutMs() / 1000}s (set UKP_UPDATE_TIMEOUT_MS to adjust)`,
|
|
302
|
+
);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (result.signal === "SIGINT" || result.status === 130) {
|
|
307
|
+
warnings.push(`endpoint '${endpoint.name}' provider cancelled`);
|
|
308
|
+
outcome.status = "interrupted";
|
|
309
|
+
interrupted = true;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (result.status !== 0 || result.error) {
|
|
314
|
+
failed = true;
|
|
315
|
+
outcome.status = "provider-failed";
|
|
316
|
+
warnings.push(`endpoint '${endpoint.name}' provider failed${providerError ? `: ${providerError}` : ""}`);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
succeeded = true;
|
|
321
|
+
outcome.status = "updated";
|
|
322
|
+
if (providerOutput) outcome.providerOutput = providerOutput;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
endpoints,
|
|
327
|
+
warnings,
|
|
328
|
+
aggregate: interrupted
|
|
329
|
+
? "cancelled"
|
|
330
|
+
: failed
|
|
331
|
+
? "failed"
|
|
332
|
+
: succeeded
|
|
333
|
+
? "succeeded"
|
|
334
|
+
: "no-success",
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// Presentation (ADR 0021 two-stage form, surface-neutral view): `body` /
|
|
340
|
+
// `diagnostics` — the adapter assigns channels.
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
export interface UpdateHumanView {
|
|
344
|
+
body: string;
|
|
345
|
+
diagnostics: string;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function renderUpdateHuman(result: UpdateOutcome): UpdateHumanView {
|
|
349
|
+
const lines: string[] = [];
|
|
350
|
+
for (const endpoint of result.endpoints) {
|
|
351
|
+
if (endpoint.status === "cancelled") break;
|
|
352
|
+
lines.push(`== ${endpoint.name} ==`);
|
|
353
|
+
lines.push("capability: update");
|
|
354
|
+
lines.push(`provider: ${endpoint.provider ?? "(none)"}`);
|
|
355
|
+
if (endpoint.status === "skipped" || endpoint.status === "plan-failed") {
|
|
356
|
+
lines.push(`status: ${endpoint.status === "plan-failed" ? "failed" : "skipped"}`);
|
|
357
|
+
lines.push(`${endpoint.status === "plan-failed" ? "error" : "message"}: ${endpoint.message}`);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
lines.push(`maintenance_scope: ${QMD_MAINTENANCE_SCOPE}`);
|
|
361
|
+
if (endpoint.status === "interrupted") {
|
|
362
|
+
lines.push("status: cancelled");
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
if (endpoint.status === "timeout") {
|
|
366
|
+
lines.push("status: failed");
|
|
367
|
+
lines.push(`error: provider timed out after ${updateTimeoutMs() / 1000}s`);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (endpoint.status === "provider-failed") {
|
|
371
|
+
lines.push("status: failed");
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
lines.push("status: updated");
|
|
375
|
+
if (endpoint.providerOutput) {
|
|
376
|
+
lines.push("");
|
|
377
|
+
lines.push("== provider output ==");
|
|
378
|
+
lines.push(endpoint.providerOutput);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
body: lines.length > 0 ? `${lines.join("\n")}\n` : "",
|
|
383
|
+
diagnostics: result.warnings.length > 0 ? `${result.warnings.join("\n")}\n` : "",
|
|
384
|
+
};
|
|
385
|
+
}
|