@jr2/cli 0.6.0 → 0.7.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/package.json +3 -3
- package/src/build.ts +4 -4
- package/src/caller-tokens.ts +108 -0
- package/src/cli.ts +33 -2
- package/src/client.ts +224 -23
- package/src/command-table.ts +269 -20
- package/src/commands/caller.ts +115 -0
- package/src/commands/completion.ts +236 -0
- package/src/commands/gates.ts +23 -0
- package/src/commands/hooks.ts +31 -0
- package/src/commands/init.ts +2 -3
- package/src/commands/logs.ts +107 -31
- package/src/commands/run.ts +77 -27
- package/src/commands/runs.ts +17 -78
- package/src/commands/send.ts +1 -1
- package/src/commands/status.ts +121 -6
- package/src/commands/steer.ts +47 -0
- package/src/commands/up.ts +373 -70
- package/src/commands/watch.ts +57 -0
- package/src/commands/whoami.ts +22 -0
- package/src/commands/workflows.ts +58 -0
- package/src/commands/workspace.ts +316 -0
- package/src/deploy.ts +233 -12
- package/src/instance-check.ts +47 -1
- package/src/instance.ts +16 -5
- package/src/kube.ts +37 -3
- package/src/mount-wait.ts +82 -0
- package/src/output.ts +3 -0
- package/src/sse.ts +1 -1
- package/src/workspaces.ts +307 -0
package/src/commands/up.ts
CHANGED
|
@@ -65,6 +65,9 @@ import { homedir } from "node:os";
|
|
|
65
65
|
import { basename, join, relative } from "node:path";
|
|
66
66
|
import { fileURLToPath } from "node:url";
|
|
67
67
|
import {
|
|
68
|
+
callerTokenDigest,
|
|
69
|
+
callerTokenSecret,
|
|
70
|
+
storedValueSha256,
|
|
68
71
|
custodianBootstrap,
|
|
69
72
|
custodianRef,
|
|
70
73
|
custodianScript,
|
|
@@ -82,8 +85,6 @@ import {
|
|
|
82
85
|
doorCompat,
|
|
83
86
|
matchCredential,
|
|
84
87
|
packAssembly,
|
|
85
|
-
configMapDigest,
|
|
86
|
-
MOUNTED_CONFIGMAPS,
|
|
87
88
|
partsOf,
|
|
88
89
|
LABEL_EFFECTOR_POD,
|
|
89
90
|
LABEL_GIT_PUSH,
|
|
@@ -95,6 +96,7 @@ import {
|
|
|
95
96
|
type SandboxEffectorPlan,
|
|
96
97
|
secretPlacementsOf,
|
|
97
98
|
storedValueLength,
|
|
99
|
+
isStdioServer,
|
|
98
100
|
effectorKey,
|
|
99
101
|
targetText,
|
|
100
102
|
harnessTokenDigest,
|
|
@@ -110,10 +112,13 @@ import {
|
|
|
110
112
|
PROVIDER_KEY_NAME,
|
|
111
113
|
type PlacementWalk,
|
|
112
114
|
type SandboxPlacement,
|
|
115
|
+
type StandingView,
|
|
113
116
|
workspaceHeldPod,
|
|
114
117
|
type JR2Config,
|
|
118
|
+
memoryBytes,
|
|
115
119
|
} from "@jr2/orchestrator/internal";
|
|
116
120
|
import { stageAssembly, stagedCopyOf } from "../assembly.ts";
|
|
121
|
+
import { awaitMounts, digestSecretsOf, mountedDigests, staleLine } from "../mount-wait.ts";
|
|
117
122
|
import { caFits, fingerprint, issueCa, issueLeaf, leafFits, trustBundles, type Pem } from "../held-pki.ts";
|
|
118
123
|
import {
|
|
119
124
|
assertEmulation,
|
|
@@ -161,6 +166,8 @@ import {
|
|
|
161
166
|
priorityClassNames,
|
|
162
167
|
priorityClassObjects,
|
|
163
168
|
REPO_CACHE,
|
|
169
|
+
STATE_PVC,
|
|
170
|
+
stateVolumePlan,
|
|
164
171
|
trustObject,
|
|
165
172
|
holdsAny,
|
|
166
173
|
LABEL_HELD_POD,
|
|
@@ -174,6 +181,7 @@ import {
|
|
|
174
181
|
CHECK_CONTAINER,
|
|
175
182
|
} from "../deploy.ts";
|
|
176
183
|
import { syncAgentsMd } from "../agents-md.ts";
|
|
184
|
+
import { planCallerTokens } from "../caller-tokens.ts";
|
|
177
185
|
import { resolveRoot } from "../instance.ts";
|
|
178
186
|
import { resolvePackage } from "../kit-version.ts";
|
|
179
187
|
import { effectorImagePaths, effectorImageRefusal, STOCK_HARNESS_IMAGE, type PushForm } from "../effector-image.ts";
|
|
@@ -195,6 +203,7 @@ import { anyNodeHolds, buildNodes, sandboxNodes, type NodeObject } from "../node
|
|
|
195
203
|
import { activity, chooseOrBail, confirmOrBail, promptLine, readSecretInput, type Io } from "../output.ts";
|
|
196
204
|
import { kindCluster, sweepImages } from "../sweep.ts";
|
|
197
205
|
import { chartFilesIn, checkInstance } from "../instance-check.ts";
|
|
206
|
+
import { standingLine } from "./status.ts";
|
|
198
207
|
import { parseCommand } from "../command-table.ts";
|
|
199
208
|
|
|
200
209
|
/** How many times one converge rebuilds a host image a sweep took before it uses it (ADR-0039).
|
|
@@ -325,6 +334,69 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
325
334
|
activity(io, ` pick a different namespace (-n) or context (--context)`);
|
|
326
335
|
return 1;
|
|
327
336
|
}
|
|
337
|
+
// --- the state volume (ADR-0065): its size before anything is applied ---------------------------
|
|
338
|
+
// A claim never shrinks, and grows only where its StorageClass allows expansion: a size that the
|
|
339
|
+
// cluster would refuse is refused here, naming the class, before the namespace is touched. Both
|
|
340
|
+
// reads THROW on a failure (`listJson`, not `getJson`): a claim that reads as absent when the API
|
|
341
|
+
// could not be read would be applied over at another size. The claim is read even when the
|
|
342
|
+
// namespace read gave nothing: `getJson` reads a failure as absent, and a list in a namespace
|
|
343
|
+
// that is not there is empty, so only a real failure refuses.
|
|
344
|
+
type StateClaim = { spec?: { storageClassName?: string; resources?: { requests?: { storage?: string } } } };
|
|
345
|
+
let stateClaim: StateClaim | undefined;
|
|
346
|
+
try {
|
|
347
|
+
stateClaim = (
|
|
348
|
+
await kube.listJson<StateClaim>({
|
|
349
|
+
kind: "persistentvolumeclaim",
|
|
350
|
+
fieldSelector: `metadata.name=${STATE_PVC}`,
|
|
351
|
+
namespace,
|
|
352
|
+
...ctx,
|
|
353
|
+
})
|
|
354
|
+
)[0];
|
|
355
|
+
} catch (e) {
|
|
356
|
+
activity(
|
|
357
|
+
io,
|
|
358
|
+
`refusing: cannot read the state volume claim ${STATE_PVC} in namespace "${namespace}" (${(e as Error).message}). Nothing was applied.`,
|
|
359
|
+
);
|
|
360
|
+
return 1;
|
|
361
|
+
}
|
|
362
|
+
const claimSize = stateClaim?.spec?.resources?.requests?.storage;
|
|
363
|
+
const claimClass = stateClaim?.spec?.storageClassName;
|
|
364
|
+
const wantVolume = config.runs?.volume;
|
|
365
|
+
const grows = claimSize !== undefined && wantVolume !== undefined && memoryBytes(wantVolume) > memoryBytes(claimSize);
|
|
366
|
+
let claimClassObject: { allowVolumeExpansion?: boolean } | undefined;
|
|
367
|
+
if (grows && claimClass) {
|
|
368
|
+
try {
|
|
369
|
+
claimClassObject = (
|
|
370
|
+
await kube.listJson<{ allowVolumeExpansion?: boolean }>({
|
|
371
|
+
kind: "storageclass",
|
|
372
|
+
fieldSelector: `metadata.name=${claimClass}`,
|
|
373
|
+
...ctx,
|
|
374
|
+
})
|
|
375
|
+
)[0];
|
|
376
|
+
} catch (e) {
|
|
377
|
+
activity(
|
|
378
|
+
io,
|
|
379
|
+
`refusing: runs.volume is ${wantVolume}, larger than the state volume claim ${STATE_PVC} (${claimSize}), and jr2 cannot read StorageClass "${claimClass}" to see if it allows expansion (${(e as Error).message}). Nothing was applied.`,
|
|
380
|
+
);
|
|
381
|
+
return 1;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const volumePlan = stateVolumePlan(
|
|
385
|
+
wantVolume,
|
|
386
|
+
claimSize === undefined
|
|
387
|
+
? undefined
|
|
388
|
+
: {
|
|
389
|
+
size: claimSize,
|
|
390
|
+
...(claimClass ? { storageClass: claimClass } : {}),
|
|
391
|
+
...(grows ? { expandable: claimClassObject?.allowVolumeExpansion === true } : {}),
|
|
392
|
+
},
|
|
393
|
+
);
|
|
394
|
+
if ("refused" in volumePlan) {
|
|
395
|
+
activity(io, `refusing: ${volumePlan.refused}. Nothing was applied.`);
|
|
396
|
+
return 1;
|
|
397
|
+
}
|
|
398
|
+
if (volumePlan.line) activity(io, volumePlan.line);
|
|
399
|
+
|
|
328
400
|
if (!ns || !owner) {
|
|
329
401
|
const what = ns ? `adopt existing namespace "${namespace}"` : `create namespace "${namespace}"`;
|
|
330
402
|
const ok =
|
|
@@ -717,6 +789,10 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
717
789
|
JR2_PUBLISH_TOKEN: publishToken,
|
|
718
790
|
JR2_SIGNING_KEY: signingKey,
|
|
719
791
|
};
|
|
792
|
+
// The named Caller tokens (ADR-0078): each declared name keeps the value its Secret holds, and a
|
|
793
|
+
// name with none gets one now. The Orchestrator gets only their digests. Read here and written
|
|
794
|
+
// with the instance objects below, so a converge that stops before the apply writes no value.
|
|
795
|
+
const callerTokens = await planCallerTokens(kube, Object.keys(config.callers?.tokens ?? {}), namespace, ctx);
|
|
720
796
|
// The Instance Harness Custodian's credentials are not here (ADR-0076): its control token and
|
|
721
797
|
// its secret Stand-ins ride its own Secret (`INSTANCE_HARNESS_CREDENTIALS`), whose Stand-ins its
|
|
722
798
|
// Harness container mounts — so it must never be this one.
|
|
@@ -770,6 +846,9 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
770
846
|
// Instance Effector — so a value edited in the user's Secret rolls them at the next `jr2 up`.
|
|
771
847
|
const referenced = new Map<string, Map<string, number> | undefined>();
|
|
772
848
|
const valueDigests = new Map<string, string>();
|
|
849
|
+
// The plain sha-256 of each stored value, for a `token` Hook's secret alone (ADR-0078): the door
|
|
850
|
+
// compares a delivery's token with it, so it cannot be keyed as `valueDigests` are.
|
|
851
|
+
const valueShas = new Map<string, string>();
|
|
773
852
|
for (const entry of config.secrets ?? []) {
|
|
774
853
|
const ref = entry.valueFrom?.secretKeyRef.name;
|
|
775
854
|
if (ref === undefined || referenced.has(ref)) continue;
|
|
@@ -785,6 +864,7 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
785
864
|
);
|
|
786
865
|
for (const [k, b64] of Object.entries(found?.data ?? {})) {
|
|
787
866
|
valueDigests.set(`${ref}/${k}`, storedValueDigest(Buffer.from(signingKey, "base64"), ref, k, b64));
|
|
867
|
+
valueShas.set(`${ref}/${k}`, storedValueSha256(b64));
|
|
788
868
|
}
|
|
789
869
|
}
|
|
790
870
|
const valueDigest = (secret: string, key: string) => valueDigests.get(`${secret}/${key}`);
|
|
@@ -800,6 +880,13 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
800
880
|
const placements = secretPlacementsOf(config, placementWalk, { secretKeys, valueLength });
|
|
801
881
|
for (const line of placements.report) activity(io, line);
|
|
802
882
|
for (const warning of placements.warnings) activity(io, `warning: ${warning}`);
|
|
883
|
+
// The `token` Hooks' digests (ADR-0078): the sha-256 of each value, which the Orchestrator mounts
|
|
884
|
+
// in `jr2-hook-digests`. The value itself never leaves this host or the user's Secret.
|
|
885
|
+
const hookDigests: Record<string, string> = {};
|
|
886
|
+
for (const t of placements.hookTokens) {
|
|
887
|
+
const digest = "value" in t ? callerTokenDigest(t.value) : valueShas.get(`${t.secret}/${t.key}`);
|
|
888
|
+
if (digest !== undefined) hookDigests[t.name] = digest;
|
|
889
|
+
}
|
|
803
890
|
const held = await resolveHeld(io, kube, {
|
|
804
891
|
pods: placements.held,
|
|
805
892
|
claimed: placements.claimed,
|
|
@@ -828,6 +915,17 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
828
915
|
`provider: ${custom.baseUrl} configured, but no carried Agent names a "${custom.id}/…" model — skipped`,
|
|
829
916
|
);
|
|
830
917
|
}
|
|
918
|
+
// Each Agent's allowlist of each remote MCP server it names (ADR-0079): the check pods list the
|
|
919
|
+
// servers' tools, and each list is held to these.
|
|
920
|
+
const mcpUses: McpUse[] = workflows.flatMap((w) =>
|
|
921
|
+
partsOf([w.machine]).mcpAgents.flatMap((a) =>
|
|
922
|
+
a.mcp.flatMap((e) => {
|
|
923
|
+
const server = config.mcp?.[e.server];
|
|
924
|
+
if (server === undefined || isStdioServer(server)) return [];
|
|
925
|
+
return [{ workflow: w.name, slot: a.slot, server: e.server, tools: e.tools ?? "*" }];
|
|
926
|
+
}),
|
|
927
|
+
),
|
|
928
|
+
);
|
|
831
929
|
await preflightProviders(io, kube, {
|
|
832
930
|
checkPods,
|
|
833
931
|
skip: values["skip-provider-preflight"] === true,
|
|
@@ -844,6 +942,8 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
844
942
|
harnessEnvData,
|
|
845
943
|
priorityClassName: priorityClassNames(config.priorityClasses).sandbox,
|
|
846
944
|
signingKey: Buffer.from(signingKey, "base64"),
|
|
945
|
+
...(config.mcp ? { mcp: config.mcp } : {}),
|
|
946
|
+
mcpUses,
|
|
847
947
|
});
|
|
848
948
|
|
|
849
949
|
// --- operator (per-cluster, shared): after the provider preflight ------------------------------
|
|
@@ -930,10 +1030,14 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
930
1030
|
const instanceList = instanceObjects({
|
|
931
1031
|
name,
|
|
932
1032
|
namespace,
|
|
1033
|
+
stateVolume: volumePlan.size,
|
|
933
1034
|
image: refs.orchestrator,
|
|
934
1035
|
secretData,
|
|
1036
|
+
callerTokens: callerTokens.values,
|
|
1037
|
+
hookDigests,
|
|
935
1038
|
harnessEnvData,
|
|
936
1039
|
harness: config.harness,
|
|
1040
|
+
...(config.mcp ? { mcp: config.mcp } : {}),
|
|
937
1041
|
held: held.objects,
|
|
938
1042
|
imageRefs: converged,
|
|
939
1043
|
repoCache: carried.composesSandbox ? { image: operatorImage!, placement: config.sandbox } : undefined,
|
|
@@ -944,6 +1048,20 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
944
1048
|
});
|
|
945
1049
|
activity(io, `orchestrator: applying (image ${refs.orchestrator})`);
|
|
946
1050
|
await kube.apply({ manifest: instanceList, warn: (line) => activity(io, line), ...ctx });
|
|
1051
|
+
// After the apply, so the line names a value that exists. It works only when the Orchestrator has
|
|
1052
|
+
// loaded its digest, which the publish below waits for (ADR-0078); `converged` says so.
|
|
1053
|
+
for (const n of callerTokens.made) {
|
|
1054
|
+
activity(
|
|
1055
|
+
io,
|
|
1056
|
+
`caller token "${n}": made — \`jr2 caller token ${n}\` prints it, and it works when \`jr2 up\` says converged`,
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
// A name that the config no longer declares loses its Secret, so its value stops working
|
|
1060
|
+
// (ADR-0078). Its digest went with the digests Secret above.
|
|
1061
|
+
for (const n of callerTokens.undeclared) {
|
|
1062
|
+
await kube.deleteObject({ kind: "secret", name: callerTokenSecret(n), namespace, ...ctx });
|
|
1063
|
+
activity(io, `caller token "${n}": deleted — callers.tokens no longer declares it`);
|
|
1064
|
+
}
|
|
947
1065
|
// An entry whose clone Secret this converge did not write loses it: a dropped entry, a token
|
|
948
1066
|
// unset, or nothing left that clones.
|
|
949
1067
|
const cloneSecrets = new Set(gitClone.map((s) => s.name));
|
|
@@ -1090,6 +1208,7 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
1090
1208
|
manifest: instance.manifest,
|
|
1091
1209
|
}),
|
|
1092
1210
|
harness: config.harness,
|
|
1211
|
+
...(config.mcp ? { mcp: config.mcp } : {}),
|
|
1093
1212
|
caBundle: caPem !== undefined,
|
|
1094
1213
|
// The wire's gate (ADR-0058): the digest of the bearer the Orchestrator derives for this
|
|
1095
1214
|
// placement from the kept key — the same shape it stamps onto every Sandbox at provision.
|
|
@@ -1129,17 +1248,19 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
1129
1248
|
await kube.deleteObject({ kind: "secret", name: INSTANCE_HARNESS_CREDENTIALS, namespace, ...ctx });
|
|
1130
1249
|
}
|
|
1131
1250
|
|
|
1132
|
-
// --- Instance Effector (ADR-0068): converged when an Effect is placed there
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1251
|
+
// --- Instance Effector (ADR-0068): converged when an Effect is placed there or a Hook needs it,
|
|
1252
|
+
// absent otherwise ---
|
|
1253
|
+
// The placement is read off the Machines and the Hooks: an `http` Effect runs there, a `mac`
|
|
1254
|
+
// Hook's check and a Hook kind's follow-up too. Its objects carry only the secrets those name,
|
|
1255
|
+
// and the placement's key, derived from the kept signing key — the key the Orchestrator derives
|
|
1256
|
+
// again to sign every call and verify every answer.
|
|
1136
1257
|
const effectorPlan = placements.instance;
|
|
1137
1258
|
if (effectorPlan) {
|
|
1138
1259
|
const names = effectorPlan.secrets.map((s) => `\`${s.name}\``).join(", ") || "none";
|
|
1260
|
+
const serves = [...effectorPlan.effects, ...effectorPlan.hooks.map((h) => `hook ${h}`)].join(", ");
|
|
1139
1261
|
activity(
|
|
1140
1262
|
io,
|
|
1141
|
-
`instance effector: converging (${
|
|
1142
|
-
`${effectorGraceSeconds(effectorPlan)}s)`,
|
|
1263
|
+
`instance effector: converging (${serves}; secrets ${names}; grace ${effectorGraceSeconds(effectorPlan)}s)`,
|
|
1143
1264
|
);
|
|
1144
1265
|
const signing = Buffer.from(signingKey, "base64");
|
|
1145
1266
|
const key = effectorKey(signing, "instance");
|
|
@@ -1174,7 +1295,7 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
1174
1295
|
...ctx,
|
|
1175
1296
|
});
|
|
1176
1297
|
} else {
|
|
1177
|
-
activity(io, "instance effector: none (no Effect is placed there)");
|
|
1298
|
+
activity(io, "instance effector: none (no Effect is placed there, and no Hook needs it)");
|
|
1178
1299
|
for (const kind of ["deployment", "service", "networkpolicy", "secret"]) {
|
|
1179
1300
|
await kube.deleteObject({ kind, name: INSTANCE_EFFECTOR, namespace, ...ctx });
|
|
1180
1301
|
}
|
|
@@ -1191,7 +1312,7 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
1191
1312
|
assembly,
|
|
1192
1313
|
token: publishToken,
|
|
1193
1314
|
force: values.force === true,
|
|
1194
|
-
mounts: mountedDigests(instanceList),
|
|
1315
|
+
mounts: mountedDigests((JSON.parse(instanceList) as { items: KubeObject[] }).items),
|
|
1195
1316
|
});
|
|
1196
1317
|
if (!published) return 1;
|
|
1197
1318
|
|
|
@@ -1245,21 +1366,12 @@ function mapRefs(map: Partial<ConvergedImages>): string[] {
|
|
|
1245
1366
|
}
|
|
1246
1367
|
|
|
1247
1368
|
/** What `GET /assembly` answers (ADR-0069), read loosely: this is the CLI's view of the door.
|
|
1248
|
-
* `mounts` is the digest of each
|
|
1249
|
-
type AssemblyView = {
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
const items = (JSON.parse(list) as { items: Array<KubeObject & { data?: Record<string, string> }> }).items;
|
|
1255
|
-
const out: Record<string, string> = {};
|
|
1256
|
-
for (const item of items) {
|
|
1257
|
-
if (item.kind === "ConfigMap" && item.metadata.name in MOUNTED_CONFIGMAPS) {
|
|
1258
|
-
out[item.metadata.name] = configMapDigest(item.data ?? {});
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
return out;
|
|
1262
|
-
}
|
|
1369
|
+
* `mounts` is the digest of each object the process reads through a mount (`MountsView`). */
|
|
1370
|
+
type AssemblyView = {
|
|
1371
|
+
current?: { hash?: string; kitVersion?: string } | null;
|
|
1372
|
+
mounts?: Record<string, string>;
|
|
1373
|
+
standing?: StandingView[];
|
|
1374
|
+
};
|
|
1263
1375
|
|
|
1264
1376
|
/**
|
|
1265
1377
|
* The key of each `file:` Sandbox Image context (ADR-0037, ADR-0049, ADR-0069): the digest of its
|
|
@@ -1293,23 +1405,18 @@ type PublishBody = {
|
|
|
1293
1405
|
error?: string;
|
|
1294
1406
|
report?: string[];
|
|
1295
1407
|
kitVersion?: unknown;
|
|
1408
|
+
/** Each standing Workflow's state after the publish (ADR-0017). */
|
|
1409
|
+
standing?: StandingView[];
|
|
1296
1410
|
};
|
|
1297
1411
|
|
|
1298
|
-
/**
|
|
1299
|
-
* How long a publish waits for the Orchestrator to read the ConfigMaps this converge wrote
|
|
1300
|
-
* (ADR-0038, ADR-0069). `jr2-images` and `jr2-held` are mounts, and the kubelet refreshes a mount
|
|
1301
|
-
* on its own period (about a minute by default). A publish rolls no pod, so until the refresh the
|
|
1302
|
-
* door's checks and the next provision read the previous data. The publish waits until the digests
|
|
1303
|
-
* `GET /assembly` reports are the ones this converge applied. Exported so a test can shorten it.
|
|
1304
|
-
*/
|
|
1305
|
-
export const MAP_PROPAGATION = { budgetMs: 150_000, stepMs: 5_000 };
|
|
1306
|
-
|
|
1307
1412
|
/**
|
|
1308
1413
|
* Publish the staged Assembly to the running Orchestrator (ADR-0069), over a port-forward of its
|
|
1309
1414
|
* Service, as `resolveTarget` reaches it. `GET /assembly` first: when the current Assembly is this
|
|
1310
1415
|
* one, at this Kit version, nothing is sent, so a steady-state converge publishes nothing. Then
|
|
1311
1416
|
* `PUT /assembly` with the publish token from the Instance Secret — never from the environment.
|
|
1312
|
-
* Before the PUT, it waits until the Orchestrator reads the mounted ConfigMaps
|
|
1417
|
+
* Before the PUT, it waits until the Orchestrator reads the mounted ConfigMaps and the digests
|
|
1418
|
+
* Secrets this converge wrote (`awaitMounts`). When nothing is sent, it still waits for the digests
|
|
1419
|
+
* Secrets, so that `converged` means a Caller token or Hook secret that this converge wrote works.
|
|
1313
1420
|
* `--force` sends the PUT even when the hashes agree; the door answers `unchanged` and replaces
|
|
1314
1421
|
* nothing, because the hash names the bytes it already holds.
|
|
1315
1422
|
*
|
|
@@ -1324,7 +1431,7 @@ async function publishAssembly(
|
|
|
1324
1431
|
assembly: { hash: string; kitVersion: string; workflows: string[]; tar: Buffer };
|
|
1325
1432
|
token: string;
|
|
1326
1433
|
force: boolean;
|
|
1327
|
-
/** The digest of each mounted
|
|
1434
|
+
/** The digest of each mounted object this converge applied, by name (`mountedDigests`). */
|
|
1328
1435
|
mounts: Record<string, string>;
|
|
1329
1436
|
},
|
|
1330
1437
|
): Promise<boolean> {
|
|
@@ -1343,34 +1450,32 @@ async function publishAssembly(
|
|
|
1343
1450
|
if (!seen.ok) throw new Error(`the Orchestrator answered GET /assembly with HTTP ${seen.status}`);
|
|
1344
1451
|
return (await seen.json()) as AssemblyView;
|
|
1345
1452
|
};
|
|
1346
|
-
|
|
1347
|
-
const current =
|
|
1348
|
-
|
|
1453
|
+
const first = await view();
|
|
1454
|
+
const current = first.current ?? null;
|
|
1455
|
+
const skip = !force && current?.hash === assembly.hash && current.kitVersion === assembly.kitVersion;
|
|
1456
|
+
// The mounts first (ADR-0069): the door checks a `file:` digest against `jr2-images`, and a
|
|
1457
|
+
// provision after the converge reads `jr2-held`. With no publish, only the digests Secrets
|
|
1458
|
+
// (ADR-0078): a token that this converge wrote works only when the Orchestrator holds its digest.
|
|
1459
|
+
const waited = await awaitMounts(io, {
|
|
1460
|
+
seen: first,
|
|
1461
|
+
view,
|
|
1462
|
+
want: skip ? digestSecretsOf(opts.mounts) : opts.mounts,
|
|
1463
|
+
prefix: "assembly",
|
|
1464
|
+
});
|
|
1465
|
+
if (waited.stale.length) {
|
|
1466
|
+
const tokens = Object.keys(digestSecretsOf(opts.mounts)).some((n) => waited.stale.includes(n));
|
|
1467
|
+
const consequence = [
|
|
1468
|
+
...(skip ? [] : ["nothing was published, and the previous Assembly is still current"]),
|
|
1469
|
+
...(tokens ? ["a Caller token or Hook secret that this converge wrote does not work yet"] : []),
|
|
1470
|
+
];
|
|
1471
|
+
activity(io, `refusing: ${staleLine(waited.stale)}; ${consequence.join("; ")}. Run \`jr2 up\` again`);
|
|
1472
|
+
return false;
|
|
1473
|
+
}
|
|
1474
|
+
if (skip) {
|
|
1349
1475
|
activity(io, `assembly: ${assembly.hash} (current — publish skipped)`);
|
|
1476
|
+
for (const s of Array.isArray(waited.seen.standing) ? waited.seen.standing : []) activity(io, standingLine(s));
|
|
1350
1477
|
return true;
|
|
1351
1478
|
}
|
|
1352
|
-
// The mounts first (ADR-0069): the door checks a `file:` digest against `jr2-images`, and a
|
|
1353
|
-
// provision after the converge reads `jr2-held`. A process that reports no mounts is not
|
|
1354
|
-
// deployed, and has nothing to wait for.
|
|
1355
|
-
const stale = (v: AssemblyView): string[] =>
|
|
1356
|
-
v.mounts ? Object.keys(opts.mounts).filter((name) => v.mounts![name] !== opts.mounts[name]) : [];
|
|
1357
|
-
const deadline = Date.now() + MAP_PROPAGATION.budgetMs;
|
|
1358
|
-
let said = false;
|
|
1359
|
-
for (let names = stale(seen); names.length; names = stale(seen)) {
|
|
1360
|
-
if (Date.now() >= deadline) {
|
|
1361
|
-
activity(
|
|
1362
|
-
io,
|
|
1363
|
-
`refusing: after ${Math.round(MAP_PROPAGATION.budgetMs / 1000)}s the Orchestrator still reads an older ` +
|
|
1364
|
-
`${names.join(", ")} from its mount — the kubelet has not refreshed it; nothing was published, and ` +
|
|
1365
|
-
`the previous Assembly is still current. Run \`jr2 up\` again`,
|
|
1366
|
-
);
|
|
1367
|
-
return false;
|
|
1368
|
-
}
|
|
1369
|
-
if (!said) activity(io, `assembly: waiting for the kubelet to deliver ${names.join(", ")} to the Orchestrator`);
|
|
1370
|
-
said = true;
|
|
1371
|
-
await new Promise((ok) => setTimeout(ok, MAP_PROPAGATION.stepMs));
|
|
1372
|
-
seen = await view();
|
|
1373
|
-
}
|
|
1374
1479
|
activity(
|
|
1375
1480
|
io,
|
|
1376
1481
|
`assembly: publishing ${assembly.hash} (${current?.hash ? `current is ${current.hash}` : "no current Assembly"})` +
|
|
@@ -1394,6 +1499,8 @@ async function publishAssembly(
|
|
|
1394
1499
|
`assembly: ${body.hash ?? assembly.hash} is current${body.unchanged ? " (unchanged)" : ""} — ` +
|
|
1395
1500
|
`workflows: ${names.join(", ") || "(none)"}`,
|
|
1396
1501
|
);
|
|
1502
|
+
// Each standing Workflow's state after the publish (ADR-0017), from the publish's answer.
|
|
1503
|
+
for (const st of Array.isArray(body.standing) ? body.standing : []) activity(io, standingLine(st));
|
|
1397
1504
|
return true;
|
|
1398
1505
|
}
|
|
1399
1506
|
const report = body.report ?? [];
|
|
@@ -2342,11 +2449,29 @@ export type CheckPod = {
|
|
|
2342
2449
|
/** The bindings of the checked keys: each a part of a held pod's manifest. */
|
|
2343
2450
|
manifest: HeldManifest;
|
|
2344
2451
|
providers: CheckedProvider[];
|
|
2452
|
+
/** The remote MCP servers that an Agent of this class names, by name, sorted (ADR-0079): the
|
|
2453
|
+
* check lists each one's tools through the Custodian, with the Stand-in of its secret. */
|
|
2454
|
+
mcp: CheckedMcpServer[];
|
|
2455
|
+
};
|
|
2456
|
+
|
|
2457
|
+
/** One remote MCP server a check pod lists (ADR-0079). */
|
|
2458
|
+
export type CheckedMcpServer = {
|
|
2459
|
+
server: string;
|
|
2460
|
+
url: string;
|
|
2461
|
+
/** Its secret, which the check pod holds as a pod of the class holds it; absent: no credential. */
|
|
2462
|
+
secret?: string;
|
|
2345
2463
|
};
|
|
2346
2464
|
|
|
2347
|
-
/** One Harness pod as the walk places it: its held pod key, its class, the models it runs,
|
|
2348
|
-
* the bindings it holds (none: its HTTPS goes direct, with no
|
|
2349
|
-
|
|
2465
|
+
/** One Harness pod as the walk places it: its held pod key, its class, the models it runs, the
|
|
2466
|
+
* MCP servers its Agents name, and the bindings it holds (none: its HTTPS goes direct, with no
|
|
2467
|
+
* proxy). */
|
|
2468
|
+
type Seat = {
|
|
2469
|
+
pod: string;
|
|
2470
|
+
sandbox: boolean;
|
|
2471
|
+
models: readonly string[];
|
|
2472
|
+
servers: readonly string[];
|
|
2473
|
+
held: HeldManifest["secrets"];
|
|
2474
|
+
};
|
|
2350
2475
|
|
|
2351
2476
|
/**
|
|
2352
2477
|
* Which providers the preflight checks, and in which pod (ADR-0019, ADR-0059). Every carried model
|
|
@@ -2368,7 +2493,8 @@ export function checkPodsOf(config: JR2Config, walk: PlacementWalk, pods: readon
|
|
|
2368
2493
|
const seats: Seat[] = [
|
|
2369
2494
|
...walk.workspaces.map((ws) => {
|
|
2370
2495
|
const pod = workspaceHeldPod({ workflow: ws.workflow, route: ws.pod.route });
|
|
2371
|
-
|
|
2496
|
+
const servers = (ws.pod.mcp ?? []).map((m) => m.entry.server);
|
|
2497
|
+
return { pod, sandbox: true, models: ws.pod.models, servers, held: heldOf(pod) };
|
|
2372
2498
|
}),
|
|
2373
2499
|
...(walk.instanceHarness
|
|
2374
2500
|
? [
|
|
@@ -2376,6 +2502,7 @@ export function checkPodsOf(config: JR2Config, walk: PlacementWalk, pods: readon
|
|
|
2376
2502
|
pod: INSTANCE_HARNESS_HELD_POD,
|
|
2377
2503
|
sandbox: false,
|
|
2378
2504
|
models: walk.instanceHarness.models,
|
|
2505
|
+
servers: walk.instanceHarness.servers ?? [],
|
|
2379
2506
|
held: heldOf(INSTANCE_HARNESS_HELD_POD),
|
|
2380
2507
|
},
|
|
2381
2508
|
]
|
|
@@ -2415,6 +2542,7 @@ export function checkPodsOf(config: JR2Config, walk: PlacementWalk, pods: readon
|
|
|
2415
2542
|
// Stand-ins) is that of a pod of the class; the keys the class's other pods hold join it below.
|
|
2416
2543
|
manifest: { version: 1, secrets: [...group.seats[0]!.held] },
|
|
2417
2544
|
providers: [],
|
|
2545
|
+
mcp: [],
|
|
2418
2546
|
};
|
|
2419
2547
|
for (const model of models) {
|
|
2420
2548
|
const slash = model.indexOf("/");
|
|
@@ -2453,7 +2581,23 @@ export function checkPodsOf(config: JR2Config, walk: PlacementWalk, pods: readon
|
|
|
2453
2581
|
...(gateway ? { gateway } : {}),
|
|
2454
2582
|
});
|
|
2455
2583
|
}
|
|
2456
|
-
|
|
2584
|
+
// Each remote MCP server that an Agent of the class names (ADR-0079), with its secret's binding
|
|
2585
|
+
// from a pod of the class that holds it: the check reads the list as a Turn there reads it. A
|
|
2586
|
+
// stdio server is Workspace code, and the check pod runs none.
|
|
2587
|
+
const servers = [...new Set(group.seats.flatMap((s) => s.servers))].sort();
|
|
2588
|
+
for (const name of servers) {
|
|
2589
|
+
const declared = config.mcp?.[name];
|
|
2590
|
+
if (declared === undefined || isStdioServer(declared)) continue;
|
|
2591
|
+
const secret = declared.secret;
|
|
2592
|
+
if (secret !== undefined) {
|
|
2593
|
+
const binding = group.seats.map((s) => s.held.find((b) => b.name === secret)).find((b) => b !== undefined);
|
|
2594
|
+
if (binding && !checkPod.manifest.secrets.some((h) => h.name === secret)) {
|
|
2595
|
+
checkPod.manifest.secrets.push(binding);
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
checkPod.mcp.push({ server: name, url: declared.url, ...(secret !== undefined ? { secret } : {}) });
|
|
2599
|
+
}
|
|
2600
|
+
if (checkPod.providers.length || checkPod.mcp.length) checkPods.push(checkPod);
|
|
2457
2601
|
}
|
|
2458
2602
|
return checkPods;
|
|
2459
2603
|
}
|
|
@@ -2467,6 +2611,9 @@ const CHECK_START_SECONDS = 300;
|
|
|
2467
2611
|
* `@jr2/harness`'s `preflight-line.ts`, which a test holds this to), so one slow model never uses
|
|
2468
2612
|
* the time of the others. Exported for that test. */
|
|
2469
2613
|
export const CHECK_MODEL_SECONDS = 360;
|
|
2614
|
+
/** Each MCP server's bound in the pod: its own budget in the check (`PREFLIGHT_MCP_BUDGET_SECONDS`
|
|
2615
|
+
* in `preflight-line.ts`, which a test holds this to). Exported for that test. */
|
|
2616
|
+
export const CHECK_MCP_SECONDS = 60;
|
|
2470
2617
|
const CHECK_RUN_MARGIN_SECONDS = 30;
|
|
2471
2618
|
/** A leftover a run left with no pod, older than this, is a dead run's, not a live one's. */
|
|
2472
2619
|
const CHECK_LEFTOVER_SECONDS = 3600;
|
|
@@ -2507,6 +2654,10 @@ async function preflightProviders(
|
|
|
2507
2654
|
priorityClassName: string;
|
|
2508
2655
|
/** The signing key, for each check pod's own Stand-ins (ADR-0019, ADR-0059). */
|
|
2509
2656
|
signingKey: Buffer;
|
|
2657
|
+
/** The config's `mcp` block, for the check pod's harness config (ADR-0079). */
|
|
2658
|
+
mcp?: JR2Config["mcp"];
|
|
2659
|
+
/** Each Agent's allowlist of each remote server it names: what each listing is held to. */
|
|
2660
|
+
mcpUses: readonly McpUse[];
|
|
2510
2661
|
},
|
|
2511
2662
|
): Promise<void> {
|
|
2512
2663
|
const { namespace, ctx } = opts;
|
|
@@ -2516,10 +2667,12 @@ async function preflightProviders(
|
|
|
2516
2667
|
// Never silent: a converge that checked nothing must not read as one that checked. A model that
|
|
2517
2668
|
// two check pods would check is named once.
|
|
2518
2669
|
const models = [...new Set(opts.checkPods.flatMap((p) => p.providers.flatMap((v) => v.models)))];
|
|
2670
|
+
const servers = [...new Set(opts.checkPods.flatMap((p) => p.mcp.map((m) => m.server)))].sort();
|
|
2519
2671
|
activity(
|
|
2520
2672
|
io,
|
|
2521
2673
|
"warning: provider: preflight skipped (--skip-provider-preflight) — " +
|
|
2522
|
-
(models.length ? `these models were not checked: ${models.join(", ")}` : "no model was checked")
|
|
2674
|
+
(models.length ? `these models were not checked: ${models.join(", ")}` : "no model was checked") +
|
|
2675
|
+
(servers.length ? `; these MCP servers' tool lists were not checked: ${servers.join(", ")}` : ""),
|
|
2523
2676
|
);
|
|
2524
2677
|
return;
|
|
2525
2678
|
}
|
|
@@ -2527,9 +2680,12 @@ async function preflightProviders(
|
|
|
2527
2680
|
const run = randomBytes(4).toString("hex");
|
|
2528
2681
|
const orchestrator = { host: `${ORCHESTRATOR_SERVICE}.${namespace}.svc`, port: ORCHESTRATOR_PORT };
|
|
2529
2682
|
const faults: string[] = [];
|
|
2683
|
+
/** An MCP result already said: two check pods can list one server, and say it once. */
|
|
2684
|
+
const saidMcp = new Set<string>();
|
|
2530
2685
|
for (const [n, checkPod] of opts.checkPods.entries()) {
|
|
2531
2686
|
const object = `${PROVIDER_PREFLIGHT}-${run}-${n}`;
|
|
2532
2687
|
const models = checkPod.providers.flatMap((p) => p.models);
|
|
2688
|
+
const servers = checkPod.mcp.map((m) => m.server);
|
|
2533
2689
|
/** The models whose result is already said, while the pod ran, and what it was: a final log
|
|
2534
2690
|
* read that comes back empty must not turn a result already said into "not checked". */
|
|
2535
2691
|
const said = new Set<string>();
|
|
@@ -2548,12 +2704,27 @@ async function preflightProviders(
|
|
|
2548
2704
|
: `with no held key (${why}), direct, as a pod that holds no secret calls it`;
|
|
2549
2705
|
activity(io, `provider: checking ${p.models.join(", ")} on ${checkPod.where}, ${holds}`);
|
|
2550
2706
|
}
|
|
2707
|
+
for (const m of checkPod.mcp) {
|
|
2708
|
+
const holds = m.secret
|
|
2709
|
+
? `through a Custodian that holds ${m.secret}, with its Stand-in`
|
|
2710
|
+
: checkPod.manifest.secrets.length && m.url.startsWith("https:")
|
|
2711
|
+
? "with no credential, through the Custodian's tunnel"
|
|
2712
|
+
: "with no credential, direct";
|
|
2713
|
+
activity(io, `mcp: listing the tools of "${m.server}" at ${m.url} on ${checkPod.where}, ${holds}`);
|
|
2714
|
+
}
|
|
2551
2715
|
activity(
|
|
2552
2716
|
io,
|
|
2553
2717
|
`provider: the check pod has up to ${CHECK_START_SECONDS / 60} min to start, then up to ` +
|
|
2554
|
-
|
|
2718
|
+
(models.length
|
|
2719
|
+
? `${CHECK_MODEL_SECONDS / 60} min for ${models.length === 1 ? "its model" : `each of its ${models.length} models`}`
|
|
2720
|
+
: "") +
|
|
2721
|
+
(models.length && servers.length ? " and " : "") +
|
|
2722
|
+
(servers.length
|
|
2723
|
+
? `${CHECK_MCP_SECONDS / 60} min for ${servers.length === 1 ? "its MCP server" : `each of its ${servers.length} MCP servers`}`
|
|
2724
|
+
: ""),
|
|
2555
2725
|
);
|
|
2556
|
-
const runSeconds =
|
|
2726
|
+
const runSeconds =
|
|
2727
|
+
CHECK_MODEL_SECONDS * models.length + CHECK_MCP_SECONDS * servers.length + CHECK_RUN_MARGIN_SECONDS;
|
|
2557
2728
|
// Built before the apply: a fault in jr2 here is jr2's, never the cluster's refusal.
|
|
2558
2729
|
const manifest = checkPodObjects({
|
|
2559
2730
|
name: opts.name,
|
|
@@ -2580,6 +2751,8 @@ async function preflightProviders(
|
|
|
2580
2751
|
}),
|
|
2581
2752
|
harnessEnvData: opts.harnessEnvData,
|
|
2582
2753
|
models,
|
|
2754
|
+
servers,
|
|
2755
|
+
...(opts.mcp ? { mcp: opts.mcp } : {}),
|
|
2583
2756
|
...(checkPod.placement ? { placement: checkPod.placement } : {}),
|
|
2584
2757
|
priorityClassName: opts.priorityClassName,
|
|
2585
2758
|
...(opts.harness ? { harness: opts.harness } : {}),
|
|
@@ -2641,10 +2814,140 @@ async function preflightProviders(
|
|
|
2641
2814
|
faults.push(providerFault(p, model, checkPod.where, why, checkPod.manifest.secrets.length > 0));
|
|
2642
2815
|
}
|
|
2643
2816
|
}
|
|
2817
|
+
const listings = mcpCheckVerdicts(output);
|
|
2818
|
+
const startedMcp = mcpCheckStarts(output);
|
|
2819
|
+
for (const m of checkPod.mcp) {
|
|
2820
|
+
const verdict = listings.get(m.server);
|
|
2821
|
+
const lines = verdict
|
|
2822
|
+
? mcpVerdictLines(m, verdict, opts.mcpUses, checkPod.where)
|
|
2823
|
+
: {
|
|
2824
|
+
faults: [
|
|
2825
|
+
`mcp: "${m.server}" at ${m.url} was not checked on ${checkPod.where}: ` +
|
|
2826
|
+
(refused ??
|
|
2827
|
+
(startedMcp.has(m.server)
|
|
2828
|
+
? `the check pod stopped while it listed this server: ${podFault(end!, checkPod, opts)}`
|
|
2829
|
+
: `the check pod stopped before it reached this server: ${podFault(end!, checkPod, opts)}`)),
|
|
2830
|
+
],
|
|
2831
|
+
said: [],
|
|
2832
|
+
};
|
|
2833
|
+
for (const line of lines.said) {
|
|
2834
|
+
if (saidMcp.has(line)) continue;
|
|
2835
|
+
saidMcp.add(line);
|
|
2836
|
+
activity(io, line);
|
|
2837
|
+
}
|
|
2838
|
+
for (const fault of lines.faults) if (!faults.includes(fault)) faults.push(fault);
|
|
2839
|
+
}
|
|
2644
2840
|
}
|
|
2645
2841
|
if (faults.length) throw new Error(faults.join("\n"));
|
|
2646
2842
|
}
|
|
2647
2843
|
|
|
2844
|
+
/** One Agent's use of one remote MCP server, as the walk read it: the allowlist, or `"*"`. */
|
|
2845
|
+
export type McpUse = { workflow: string; slot: string; server: string; tools: readonly string[] | "*" };
|
|
2846
|
+
|
|
2847
|
+
/** One MCP server's result line (`preflight-line.ts` in `@jr2/harness`). */
|
|
2848
|
+
type McpCheckVerdict = { kind: "listed"; tools: string[] } | { kind: "refused" | "warn" | "fault"; reason: string };
|
|
2849
|
+
|
|
2850
|
+
/** The servers whose listing began (`start` lines). */
|
|
2851
|
+
export function mcpCheckStarts(output: string): Set<string> {
|
|
2852
|
+
const started = new Set<string>();
|
|
2853
|
+
for (const line of output.split("\n")) {
|
|
2854
|
+
const m = /^jr2\.preflight mcp=(\S+) start$/.exec(line.trim());
|
|
2855
|
+
if (m) started.add(m[1]!);
|
|
2856
|
+
}
|
|
2857
|
+
return started;
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
/** Each MCP server's verdict, off the check container's output lines (`preflight-line.ts` in
|
|
2861
|
+
* `@jr2/harness`, held to this parser by a test). A list that is not JSON is the check's fault. */
|
|
2862
|
+
export function mcpCheckVerdicts(output: string): Map<string, McpCheckVerdict> {
|
|
2863
|
+
const verdicts = new Map<string, McpCheckVerdict>();
|
|
2864
|
+
for (const line of output.split("\n")) {
|
|
2865
|
+
const m = /^jr2\.preflight mcp=(\S+) (listed|refused|warn|fault)=(.*)$/.exec(line.trim());
|
|
2866
|
+
if (!m) continue;
|
|
2867
|
+
if (m[2] !== "listed") {
|
|
2868
|
+
verdicts.set(m[1]!, { kind: m[2] as "refused" | "warn" | "fault", reason: m[3]! });
|
|
2869
|
+
continue;
|
|
2870
|
+
}
|
|
2871
|
+
let tools: unknown;
|
|
2872
|
+
try {
|
|
2873
|
+
tools = JSON.parse(m[3]!);
|
|
2874
|
+
} catch {
|
|
2875
|
+
tools = undefined;
|
|
2876
|
+
}
|
|
2877
|
+
verdicts.set(
|
|
2878
|
+
m[1]!,
|
|
2879
|
+
Array.isArray(tools) && tools.every((t) => typeof t === "string")
|
|
2880
|
+
? { kind: "listed", tools: tools as string[] }
|
|
2881
|
+
: { kind: "fault", reason: `the check wrote a tool list that is not a JSON list of names — a jr2 defect` },
|
|
2882
|
+
);
|
|
2883
|
+
}
|
|
2884
|
+
return verdicts;
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
/**
|
|
2888
|
+
* What one MCP server's listing comes to (ADR-0079): the lines said, and the faults. An allowlisted
|
|
2889
|
+
* name that the list does not have is refused, with what the server has; a server that refused the
|
|
2890
|
+
* request (401, 403) fails the converge, as a model key that the provider refuses does; a server
|
|
2891
|
+
* that did not answer is a warning. Pure, for its test.
|
|
2892
|
+
*/
|
|
2893
|
+
export function mcpVerdictLines(
|
|
2894
|
+
m: CheckedMcpServer,
|
|
2895
|
+
verdict: McpCheckVerdict,
|
|
2896
|
+
uses: readonly McpUse[],
|
|
2897
|
+
where: string,
|
|
2898
|
+
): { said: string[]; faults: string[] } {
|
|
2899
|
+
if (verdict.kind === "warn") {
|
|
2900
|
+
return {
|
|
2901
|
+
said: [
|
|
2902
|
+
`warning: mcp: "${m.server}" at ${m.url} did not answer from a pod on ${where} (${verdict.reason}) — its ` +
|
|
2903
|
+
"tool names are not checked; a Turn of an Agent that names it fails if it does not answer then (ADR-0079)",
|
|
2904
|
+
],
|
|
2905
|
+
faults: [],
|
|
2906
|
+
};
|
|
2907
|
+
}
|
|
2908
|
+
if (verdict.kind === "fault") {
|
|
2909
|
+
return { said: [], faults: [`mcp: "${m.server}" was not checked on ${where}: ${verdict.reason}`] };
|
|
2910
|
+
}
|
|
2911
|
+
if (verdict.kind === "refused") {
|
|
2912
|
+
return {
|
|
2913
|
+
said: [],
|
|
2914
|
+
faults: [
|
|
2915
|
+
`mcp: "${m.server}" at ${m.url} refused the request from a pod on ${where} (HTTP ${verdict.reason}) — ` +
|
|
2916
|
+
(m.secret
|
|
2917
|
+
? `a wrong or expired value of ${m.secret}, or a binding of it (hosts, headers, paths) that does not let ` +
|
|
2918
|
+
"its Stand-in through to the url"
|
|
2919
|
+
: "the server needs a credential: give the server a `secret`") +
|
|
2920
|
+
"; every call of an Agent that names it would fail (ADR-0079)",
|
|
2921
|
+
],
|
|
2922
|
+
};
|
|
2923
|
+
}
|
|
2924
|
+
if (verdict.kind !== "listed") return { said: [], faults: [] };
|
|
2925
|
+
const listed = verdict.tools;
|
|
2926
|
+
const missing = new Map<string, string[]>();
|
|
2927
|
+
for (const use of uses) {
|
|
2928
|
+
if (use.server !== m.server || use.tools === "*") continue;
|
|
2929
|
+
for (const tool of use.tools) {
|
|
2930
|
+
if (listed.includes(tool)) continue;
|
|
2931
|
+
const by = `workflow "${use.workflow}": Agent "${use.slot}"`;
|
|
2932
|
+
missing.set(tool, [...new Set([...(missing.get(tool) ?? []), by])]);
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
if (missing.size === 0) {
|
|
2936
|
+
return {
|
|
2937
|
+
said: [`mcp: "${m.server}" lists ${listed.length} tool(s); every allowlisted name is there`],
|
|
2938
|
+
faults: [],
|
|
2939
|
+
};
|
|
2940
|
+
}
|
|
2941
|
+
const has = listed.length ? listed.join(", ") : "no tool";
|
|
2942
|
+
return {
|
|
2943
|
+
said: [],
|
|
2944
|
+
faults: [...missing].map(
|
|
2945
|
+
([tool, by]) =>
|
|
2946
|
+
`mcp: ${by.join(", ")} allowlists the tool "${tool}", which "${m.server}" does not list — it lists: ${has} (ADR-0079)`,
|
|
2947
|
+
),
|
|
2948
|
+
};
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2648
2951
|
/** How long the first apply in a new namespace waits for its `default` ServiceAccount: the
|
|
2649
2952
|
* controller makes it a moment after the namespace, and until then a pod is refused. */
|
|
2650
2953
|
const FIRST_CONTACT_TRIES = 10;
|
|
@@ -2853,7 +3156,7 @@ export function podFault(
|
|
|
2853
3156
|
return `the check pod ended ${end.phase}${said}, and jr2 up could not read its log — run \`jr2 up\` again`;
|
|
2854
3157
|
}
|
|
2855
3158
|
const tail = end.output.trim().split("\n").filter(Boolean).slice(-5).join("; ");
|
|
2856
|
-
return `the check pod ended ${end.phase} before it checked this
|
|
3159
|
+
return `the check pod ended ${end.phase} before it checked this one${said} — it said: ${tail}`;
|
|
2857
3160
|
}
|
|
2858
3161
|
|
|
2859
3162
|
/** One model's preflight failure: the model, the host, and the likely causes. */
|