@manifest-network/manifest-agent-core 0.13.1 → 0.14.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.
Files changed (30) hide show
  1. package/dist/deploy-app.d.ts.map +1 -1
  2. package/dist/deploy-app.js +83 -18
  3. package/dist/deploy-app.js.map +1 -1
  4. package/dist/index.d.ts +2 -2
  5. package/dist/internals/build-fred-input.d.ts +9 -1
  6. package/dist/internals/build-fred-input.d.ts.map +1 -1
  7. package/dist/internals/build-fred-input.js +16 -3
  8. package/dist/internals/build-fred-input.js.map +1 -1
  9. package/dist/internals/evaluate-readiness-from-fred.d.ts.map +1 -1
  10. package/dist/internals/evaluate-readiness-from-fred.js +12 -3
  11. package/dist/internals/evaluate-readiness-from-fred.js.map +1 -1
  12. package/dist/internals/evaluate-readiness.d.ts +14 -0
  13. package/dist/internals/evaluate-readiness.d.ts.map +1 -1
  14. package/dist/internals/evaluate-readiness.js +21 -8
  15. package/dist/internals/evaluate-readiness.js.map +1 -1
  16. package/dist/internals/render-deployment-plan.d.ts +6 -0
  17. package/dist/internals/render-deployment-plan.d.ts.map +1 -1
  18. package/dist/internals/render-deployment-plan.js +8 -5
  19. package/dist/internals/render-deployment-plan.js.map +1 -1
  20. package/dist/internals/spec-normalize.d.ts +4 -3
  21. package/dist/internals/spec-normalize.d.ts.map +1 -1
  22. package/dist/internals/spec-normalize.js +4 -3
  23. package/dist/internals/spec-normalize.js.map +1 -1
  24. package/dist/types.d.ts +43 -2
  25. package/dist/types.d.ts.map +1 -1
  26. package/package.json +3 -3
  27. package/dist/internals/find-sku-uuid.d.ts +0 -40
  28. package/dist/internals/find-sku-uuid.d.ts.map +0 -1
  29. package/dist/internals/find-sku-uuid.js +0 -20
  30. package/dist/internals/find-sku-uuid.js.map +0 -1
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { AgentCoreRuntime, CloseLeaseArgs, CloseLeaseCallbacks, CloseLeaseOptions, CloseLeaseResult, Coin, CosmosClientManager, DenomLookup, DenomMap, DeployAppCallbacks, DeployAppOptions, DeployResult, DeploySpec, DeploymentPlanBlock, FailureEnvelope, FeeEstimate, LeaseStateName, ManageDomainArgs, ManageDomainCallbacks, ManageDomainOptions, ManageDomainResult, Plan, PlanEdit, PlanFees, ProgressEvent, Readiness, ReadinessAction, RecoveryChoice, RecoveryOption, RecoveryOptionId, ServiceDef, SingleServiceSpec, SpecSummary, StackSpec, TroubleshootArgs, TroubleshootCallbacks, TroubleshootOptions, TroubleshootReport, WalletProvider } from "./types.js";
1
+ import { AgentCoreRuntime, CloseLeaseArgs, CloseLeaseCallbacks, CloseLeaseOptions, CloseLeaseResult, Coin, CosmosClientManager, DenomLookup, DenomMap, DeployAppCallbacks, DeployAppOptions, DeployResult, DeploySpec, DeploymentPlanBlock, FailureEnvelope, FeeEstimate, LeaseStateName, ManageDomainArgs, ManageDomainCallbacks, ManageDomainOptions, ManageDomainResult, Plan, PlanEdit, PlanFees, ProgressEvent, Readiness, ReadinessAction, RecoveryChoice, RecoveryOption, RecoveryOptionId, ServiceDef, SingleServiceSpec, SkuCandidate, SpecSummary, StackSpec, TroubleshootArgs, TroubleshootCallbacks, TroubleshootOptions, TroubleshootReport, WalletProvider } from "./types.js";
2
2
  import { closeLease } from "./close-lease.js";
3
3
  import { deployApp } from "./deploy-app.js";
4
4
  import { GuardedFetch, createGuardedFetch } from "./internals/guarded-fetch.js";
5
5
  import { loadChainDenomMap } from "./internals/humanize-denom.js";
6
6
  import { manageDomain } from "./manage-domain.js";
7
7
  import { troubleshootDeployment } from "./troubleshoot.js";
8
- export { AgentCoreRuntime, CloseLeaseArgs, CloseLeaseCallbacks, CloseLeaseOptions, CloseLeaseResult, Coin, type CosmosClientManager, DenomLookup, DenomMap, DeployAppCallbacks, DeployAppOptions, DeployResult, DeploySpec, DeploymentPlanBlock, FailureEnvelope, FeeEstimate, type GuardedFetch, LeaseStateName, ManageDomainArgs, ManageDomainCallbacks, ManageDomainOptions, ManageDomainResult, Plan, PlanEdit, PlanFees, ProgressEvent, Readiness, ReadinessAction, RecoveryChoice, RecoveryOption, RecoveryOptionId, ServiceDef, SingleServiceSpec, SpecSummary, StackSpec, TroubleshootArgs, TroubleshootCallbacks, TroubleshootOptions, TroubleshootReport, type WalletProvider, closeLease, createGuardedFetch, deployApp, loadChainDenomMap, manageDomain, troubleshootDeployment };
8
+ export { AgentCoreRuntime, CloseLeaseArgs, CloseLeaseCallbacks, CloseLeaseOptions, CloseLeaseResult, Coin, type CosmosClientManager, DenomLookup, DenomMap, DeployAppCallbacks, DeployAppOptions, DeployResult, DeploySpec, DeploymentPlanBlock, FailureEnvelope, FeeEstimate, type GuardedFetch, LeaseStateName, ManageDomainArgs, ManageDomainCallbacks, ManageDomainOptions, ManageDomainResult, Plan, PlanEdit, PlanFees, ProgressEvent, Readiness, ReadinessAction, RecoveryChoice, RecoveryOption, RecoveryOptionId, ServiceDef, SingleServiceSpec, type SkuCandidate, SpecSummary, StackSpec, TroubleshootArgs, TroubleshootCallbacks, TroubleshootOptions, TroubleshootReport, type WalletProvider, closeLease, createGuardedFetch, deployApp, loadChainDenomMap, manageDomain, troubleshootDeployment };
@@ -31,8 +31,16 @@ declare function buildManifestPreviewInput(spec: DeploySpec, _size: string): Bui
31
31
  * meta-hash recorded on-chain matches the manifest actually uploaded).
32
32
  * Additionally threads `customDomain` (both shapes) and `serviceName`
33
33
  * (stack only — single-service leases have no service-name to address).
34
+ *
35
+ * @param pin Optional pre-resolved SKU pin (ENG-258). When supplied, spreads
36
+ * `skuUuid` and `providerUuid` into the output so fred's `deployApp` skips
37
+ * the name-based lookup (uses the `resolved` selector path). Existing
38
+ * 2-arg callers are unaffected — the parameter is optional.
34
39
  */
35
- declare function buildFredDeployInput(spec: DeploySpec, size: string): DeployAppInput;
40
+ declare function buildFredDeployInput(spec: DeploySpec, size: string, pin?: {
41
+ skuUuid: string;
42
+ providerUuid: string;
43
+ }): DeployAppInput;
36
44
  //#endregion
37
45
  export { buildFredDeployInput, buildManifestPreviewInput };
38
46
  //# sourceMappingURL=build-fred-input.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"build-fred-input.d.ts","names":[],"sources":["../../src/internals/build-fred-input.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;iBA6JgB,yBAAA,CACd,IAAA,EAAM,UAAA,EAGN,KAAA,WACC,yBAAyB;;;;;;;;;iBAoBZ,oBAAA,CACd,IAAA,EAAM,UAAA,EACN,IAAA,WACC,cAAkB"}
1
+ {"version":3,"file":"build-fred-input.d.ts","names":[],"sources":["../../src/internals/build-fred-input.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;iBA6JgB,yBAAA,CACd,IAAA,EAAM,UAAA,EAGN,KAAA,WACC,yBAAyB;;;;;;;;;;;;;;iBAyBZ,oBAAA,CACd,IAAA,EAAM,UAAA,EACN,IAAA,UACA,GAAA;EAAQ,OAAA;EAAiB,YAAA;AAAA,IACxB,cAAkB"}
@@ -118,12 +118,21 @@ function buildManifestPreviewInput(spec, _size) {
118
118
  * meta-hash recorded on-chain matches the manifest actually uploaded).
119
119
  * Additionally threads `customDomain` (both shapes) and `serviceName`
120
120
  * (stack only — single-service leases have no service-name to address).
121
+ *
122
+ * @param pin Optional pre-resolved SKU pin (ENG-258). When supplied, spreads
123
+ * `skuUuid` and `providerUuid` into the output so fred's `deployApp` skips
124
+ * the name-based lookup (uses the `resolved` selector path). Existing
125
+ * 2-arg callers are unaffected — the parameter is optional.
121
126
  */
122
- function buildFredDeployInput(spec, size) {
127
+ function buildFredDeployInput(spec, size, pin) {
123
128
  if (isStackSpec(spec)) {
124
129
  const out = {
125
130
  size,
126
- services: convertStackServices(spec)
131
+ services: convertStackServices(spec),
132
+ ...pin ? {
133
+ skuUuid: pin.skuUuid,
134
+ providerUuid: pin.providerUuid
135
+ } : {}
127
136
  };
128
137
  if (typeof spec.customDomain === "string" && spec.customDomain.length > 0) {
129
138
  out.customDomain = spec.customDomain;
@@ -134,7 +143,11 @@ function buildFredDeployInput(spec, size) {
134
143
  const port = narrowSingleServicePort(spec.port);
135
144
  const out = {
136
145
  size,
137
- image: spec.image
146
+ image: spec.image,
147
+ ...pin ? {
148
+ skuUuid: pin.skuUuid,
149
+ providerUuid: pin.providerUuid
150
+ } : {}
138
151
  };
139
152
  if (port !== void 0) out.port = port;
140
153
  if (spec.env !== void 0) out.env = spec.env;
@@ -1 +1 @@
1
- {"version":3,"file":"build-fred-input.js","names":[],"sources":["../../src/internals/build-fred-input.ts"],"sourcesContent":["/**\n * Discriminated-union narrowing builders: typed `DeploySpec` → fred's\n * `BuildManifestPreviewInput` and `DeployAppInput`. ENG-185 sub-PR A, item 2.\n *\n * Replaces the prior inline builders in `deploy-app.ts` (PR-3 commit B) that\n * used `as unknown as <fred-input>` casts and silently truncated\n * `SingleServiceSpec.port` arrays to `port[0]`.\n *\n * Bugs this file kills:\n *\n * (a) Stack `ServiceDef.ports: number[]` (`types.ts:164`) is converted\n * to fred's canonical port-map shape `Record<string, Record<string,\n * never>>` (e.g. `{'80/tcp': {}}`). The prior inline cast passed\n * the raw array straight through, so `buildManifestPreview` and\n * fred's deploy-time builder saw different shapes — the meta-hash\n * recorded on-chain drifted from the manifest actually uploaded.\n *\n * (b) Single-service `SingleServiceSpec.port: number | number[]`\n * (`types.ts:172`) — fred's image-mode input is `port: number`\n * only. The prior inline builders did `port[0]`, silently dropping\n * every other element. We now reject multi-element arrays with\n * `ManifestMCPError(INVALID_CONFIG)` (strategy (a) per the task\n * brief) and point the caller at `StackSpec` for natively\n * multi-port deployments.\n *\n * No `as unknown as` casts. Discriminated narrowing uses the shared\n * `isStackSpec` predicate from `spec-normalize.ts`.\n */\n\nimport {\n ManifestMCPError,\n ManifestMCPErrorCode,\n} from '@manifest-network/manifest-mcp-core';\nimport type {\n BuildManifestPreviewInput,\n DeployAppInput as FredDeployAppInput,\n} from '@manifest-network/manifest-mcp-fred';\nimport type { DeploySpec, ServiceDef, StackSpec } from '../types.js';\nimport { isStackSpec } from './spec-normalize.js';\n\n/**\n * Fred's canonical service port-map shape (`{<port>/<protocol>: {}}`).\n * Mirrors `ServiceConfig.ports` and `ManifestPreviewServiceInput.ports`\n * in `packages/fred/src/tools/deployApp.ts` /\n * `packages/fred/src/tools/buildManifestPreview.ts`. The empty `{}`\n * value is required by fred's validator (see\n * `packages/fred/src/manifest.ts`).\n */\ntype FredPortMap = Record<string, Record<string, never>>;\n\n/**\n * Internal shape used to build both preview and deploy service entries.\n * Assignable to both `ManifestPreviewServiceInput` (readonly fields) and\n * fred's `ServiceConfig` (mutable fields) — mutable arrays widen to\n * readonly arrays at the assignment site.\n */\ninterface ConvertedService {\n image: string;\n ports?: FredPortMap;\n env?: Record<string, string>;\n args?: string[];\n command?: string[];\n}\n\n/**\n * Convert a `ServiceDef.ports: number[]` to fred's canonical port-map\n * shape. Protocol defaults to `tcp` (the only protocol exposed via the\n * agent-core surface today; UDP is deferred until a caller needs it).\n */\nfunction toPortMap(ports: readonly number[]): FredPortMap {\n const map: FredPortMap = {};\n for (const port of ports) {\n map[`${port}/tcp`] = {};\n }\n return map;\n}\n\n/**\n * Map a single `ServiceDef` (agent-core spec shape) → fred's\n * `ServiceConfig` shape. Omits optional fields when absent so callers\n * can compare emitted objects with `'foo' in out` checks (rather than\n * `out.foo === undefined`, which a `key: undefined` spread would defeat).\n */\nfunction convertServiceDef(svc: ServiceDef): ConvertedService {\n const out: ConvertedService = { image: svc.image };\n if (svc.ports !== undefined && svc.ports.length > 0) {\n out.ports = toPortMap(svc.ports);\n }\n if (svc.env !== undefined) out.env = svc.env;\n if (svc.args !== undefined) out.args = [...svc.args];\n if (svc.command !== undefined) out.command = [...svc.command];\n return out;\n}\n\nfunction convertStackServices(\n spec: StackSpec,\n): Record<string, ConvertedService> {\n const out: Record<string, ConvertedService> = {};\n for (const [name, svc] of Object.entries(spec.services)) {\n out[name] = convertServiceDef(svc);\n }\n return out;\n}\n\n/**\n * Narrow `SingleServiceSpec.port: number | number[] | undefined` to\n * fred's single-service `port: number | undefined`.\n *\n * - `undefined` → `undefined`\n * - `80` → `80`\n * - `[80]` → `80` (single-element array convenience)\n * - `[80, 443, …]` → `ManifestMCPError(INVALID_CONFIG)`\n *\n * Strategy (a) per the ENG-185 sub-PR A brief: rejecting multi-element\n * arrays at the builder boundary kills the prior silent `port[0]`\n * truncation. The error message points the caller at the `StackSpec`\n * escape hatch where multi-port routing is natively expressible.\n */\nfunction narrowSingleServicePort(\n port: number | number[] | undefined,\n): number | undefined {\n if (port === undefined) return undefined;\n if (typeof port === 'number') return port;\n if (Array.isArray(port)) {\n if (port.length === 0) return undefined;\n if (port.length === 1) return port[0];\n throw new ManifestMCPError(\n ManifestMCPErrorCode.INVALID_CONFIG,\n 'SingleServiceSpec.port: multi-port single-service is not supported — use a StackSpec with explicit `services.<name>.ports` to expose multiple ports.',\n );\n }\n // Unreachable under the typed contract; defensive for `as unknown as`\n // callers that bypass the compile-time guard.\n return undefined;\n}\n\n/**\n * Build fred's `BuildManifestPreviewInput` from a typed `DeploySpec`.\n *\n * Stack arm: each `ServiceDef.ports: number[]` is converted to\n * `{<port>/tcp: {}}`. Services without `ports` (or with empty `[]`)\n * omit the `ports` key in the output.\n *\n * Single-service arm: `port: number` and `port: [80]` both produce\n * `port: 80`. Multi-element arrays throw INVALID_CONFIG.\n *\n * Deploy-only fields (`customDomain`, `serviceName`) are deliberately\n * NOT forwarded — the preview path computes only the manifest meta-hash;\n * domain claims happen later in the deploy path.\n *\n * The `size` parameter is accepted for signature parity with\n * `buildFredDeployInput` but is NOT included in the returned object:\n * fred's `BuildManifestPreviewInput` type has no `size` field and\n * `buildManifestPreview` derives no behavior from it. The prior inline\n * builder emitted `size` via an `as unknown as` cast that hid the\n * type-contract violation; this version drops it cleanly.\n */\nexport function buildManifestPreviewInput(\n spec: DeploySpec,\n // Reserved for signature parity with buildFredDeployInput; not consumed.\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n _size: string,\n): BuildManifestPreviewInput {\n if (isStackSpec(spec)) {\n return { services: convertStackServices(spec) };\n }\n const port = narrowSingleServicePort(spec.port);\n return {\n image: spec.image,\n ...(port !== undefined ? { port } : {}),\n ...(spec.env !== undefined ? { env: spec.env } : {}),\n };\n}\n\n/**\n * Build fred's `DeployAppInput` from a typed `DeploySpec`.\n *\n * Same port-shape conversion as `buildManifestPreviewInput` (so the\n * meta-hash recorded on-chain matches the manifest actually uploaded).\n * Additionally threads `customDomain` (both shapes) and `serviceName`\n * (stack only — single-service leases have no service-name to address).\n */\nexport function buildFredDeployInput(\n spec: DeploySpec,\n size: string,\n): FredDeployAppInput {\n if (isStackSpec(spec)) {\n const out: FredDeployAppInput = {\n size,\n services: convertStackServices(spec),\n };\n if (typeof spec.customDomain === 'string' && spec.customDomain.length > 0) {\n out.customDomain = spec.customDomain;\n if (typeof spec.serviceName === 'string' && spec.serviceName.length > 0) {\n out.serviceName = spec.serviceName;\n }\n }\n return out;\n }\n const port = narrowSingleServicePort(spec.port);\n const out: FredDeployAppInput = { size, image: spec.image };\n if (port !== undefined) out.port = port;\n if (spec.env !== undefined) out.env = spec.env;\n if (typeof spec.customDomain === 'string' && spec.customDomain.length > 0) {\n out.customDomain = spec.customDomain;\n }\n return out;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqEA,SAAS,UAAU,OAAuC;CACxD,MAAM,MAAmB,CAAC;CAC1B,KAAK,MAAM,QAAQ,OACjB,IAAI,GAAG,KAAK,SAAS,CAAC;CAExB,OAAO;AACT;;;;;;;AAQA,SAAS,kBAAkB,KAAmC;CAC5D,MAAM,MAAwB,EAAE,OAAO,IAAI,MAAM;CACjD,IAAI,IAAI,UAAU,KAAA,KAAa,IAAI,MAAM,SAAS,GAChD,IAAI,QAAQ,UAAU,IAAI,KAAK;CAEjC,IAAI,IAAI,QAAQ,KAAA,GAAW,IAAI,MAAM,IAAI;CACzC,IAAI,IAAI,SAAS,KAAA,GAAW,IAAI,OAAO,CAAC,GAAG,IAAI,IAAI;CACnD,IAAI,IAAI,YAAY,KAAA,GAAW,IAAI,UAAU,CAAC,GAAG,IAAI,OAAO;CAC5D,OAAO;AACT;AAEA,SAAS,qBACP,MACkC;CAClC,MAAM,MAAwC,CAAC;CAC/C,KAAK,MAAM,CAAC,MAAM,QAAQ,OAAO,QAAQ,KAAK,QAAQ,GACpD,IAAI,QAAQ,kBAAkB,GAAG;CAEnC,OAAO;AACT;;;;;;;;;;;;;;;AAgBA,SAAS,wBACP,MACoB;CACpB,IAAI,SAAS,KAAA,GAAW,OAAO,KAAA;CAC/B,IAAI,OAAO,SAAS,UAAU,OAAO;CACrC,IAAI,MAAM,QAAQ,IAAI,GAAG;EACvB,IAAI,KAAK,WAAW,GAAG,OAAO,KAAA;EAC9B,IAAI,KAAK,WAAW,GAAG,OAAO,KAAK;EACnC,MAAM,IAAI,iBACR,qBAAqB,gBACrB,sJACF;CACF;AAIF;;;;;;;;;;;;;;;;;;;;;;AAuBA,SAAgB,0BACd,MAGA,OAC2B;CAC3B,IAAI,YAAY,IAAI,GAClB,OAAO,EAAE,UAAU,qBAAqB,IAAI,EAAE;CAEhD,MAAM,OAAO,wBAAwB,KAAK,IAAI;CAC9C,OAAO;EACL,OAAO,KAAK;EACZ,GAAI,SAAS,KAAA,IAAY,EAAE,KAAK,IAAI,CAAC;EACrC,GAAI,KAAK,QAAQ,KAAA,IAAY,EAAE,KAAK,KAAK,IAAI,IAAI,CAAC;CACpD;AACF;;;;;;;;;AAUA,SAAgB,qBACd,MACA,MACoB;CACpB,IAAI,YAAY,IAAI,GAAG;EACrB,MAAM,MAA0B;GAC9B;GACA,UAAU,qBAAqB,IAAI;EACrC;EACA,IAAI,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,SAAS,GAAG;GACzE,IAAI,eAAe,KAAK;GACxB,IAAI,OAAO,KAAK,gBAAgB,YAAY,KAAK,YAAY,SAAS,GACpE,IAAI,cAAc,KAAK;EAE3B;EACA,OAAO;CACT;CACA,MAAM,OAAO,wBAAwB,KAAK,IAAI;CAC9C,MAAM,MAA0B;EAAE;EAAM,OAAO,KAAK;CAAM;CAC1D,IAAI,SAAS,KAAA,GAAW,IAAI,OAAO;CACnC,IAAI,KAAK,QAAQ,KAAA,GAAW,IAAI,MAAM,KAAK;CAC3C,IAAI,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,SAAS,GACtE,IAAI,eAAe,KAAK;CAE1B,OAAO;AACT"}
1
+ {"version":3,"file":"build-fred-input.js","names":[],"sources":["../../src/internals/build-fred-input.ts"],"sourcesContent":["/**\n * Discriminated-union narrowing builders: typed `DeploySpec` → fred's\n * `BuildManifestPreviewInput` and `DeployAppInput`. ENG-185 sub-PR A, item 2.\n *\n * Replaces the prior inline builders in `deploy-app.ts` (PR-3 commit B) that\n * used `as unknown as <fred-input>` casts and silently truncated\n * `SingleServiceSpec.port` arrays to `port[0]`.\n *\n * Bugs this file kills:\n *\n * (a) Stack `ServiceDef.ports: number[]` (`types.ts:164`) is converted\n * to fred's canonical port-map shape `Record<string, Record<string,\n * never>>` (e.g. `{'80/tcp': {}}`). The prior inline cast passed\n * the raw array straight through, so `buildManifestPreview` and\n * fred's deploy-time builder saw different shapes — the meta-hash\n * recorded on-chain drifted from the manifest actually uploaded.\n *\n * (b) Single-service `SingleServiceSpec.port: number | number[]`\n * (`types.ts:172`) — fred's image-mode input is `port: number`\n * only. The prior inline builders did `port[0]`, silently dropping\n * every other element. We now reject multi-element arrays with\n * `ManifestMCPError(INVALID_CONFIG)` (strategy (a) per the task\n * brief) and point the caller at `StackSpec` for natively\n * multi-port deployments.\n *\n * No `as unknown as` casts. Discriminated narrowing uses the shared\n * `isStackSpec` predicate from `spec-normalize.ts`.\n */\n\nimport {\n ManifestMCPError,\n ManifestMCPErrorCode,\n} from '@manifest-network/manifest-mcp-core';\nimport type {\n BuildManifestPreviewInput,\n DeployAppInput as FredDeployAppInput,\n} from '@manifest-network/manifest-mcp-fred';\nimport type { DeploySpec, ServiceDef, StackSpec } from '../types.js';\nimport { isStackSpec } from './spec-normalize.js';\n\n/**\n * Fred's canonical service port-map shape (`{<port>/<protocol>: {}}`).\n * Mirrors `ServiceConfig.ports` and `ManifestPreviewServiceInput.ports`\n * in `packages/fred/src/tools/deployApp.ts` /\n * `packages/fred/src/tools/buildManifestPreview.ts`. The empty `{}`\n * value is required by fred's validator (see\n * `packages/fred/src/manifest.ts`).\n */\ntype FredPortMap = Record<string, Record<string, never>>;\n\n/**\n * Internal shape used to build both preview and deploy service entries.\n * Assignable to both `ManifestPreviewServiceInput` (readonly fields) and\n * fred's `ServiceConfig` (mutable fields) — mutable arrays widen to\n * readonly arrays at the assignment site.\n */\ninterface ConvertedService {\n image: string;\n ports?: FredPortMap;\n env?: Record<string, string>;\n args?: string[];\n command?: string[];\n}\n\n/**\n * Convert a `ServiceDef.ports: number[]` to fred's canonical port-map\n * shape. Protocol defaults to `tcp` (the only protocol exposed via the\n * agent-core surface today; UDP is deferred until a caller needs it).\n */\nfunction toPortMap(ports: readonly number[]): FredPortMap {\n const map: FredPortMap = {};\n for (const port of ports) {\n map[`${port}/tcp`] = {};\n }\n return map;\n}\n\n/**\n * Map a single `ServiceDef` (agent-core spec shape) → fred's\n * `ServiceConfig` shape. Omits optional fields when absent so callers\n * can compare emitted objects with `'foo' in out` checks (rather than\n * `out.foo === undefined`, which a `key: undefined` spread would defeat).\n */\nfunction convertServiceDef(svc: ServiceDef): ConvertedService {\n const out: ConvertedService = { image: svc.image };\n if (svc.ports !== undefined && svc.ports.length > 0) {\n out.ports = toPortMap(svc.ports);\n }\n if (svc.env !== undefined) out.env = svc.env;\n if (svc.args !== undefined) out.args = [...svc.args];\n if (svc.command !== undefined) out.command = [...svc.command];\n return out;\n}\n\nfunction convertStackServices(\n spec: StackSpec,\n): Record<string, ConvertedService> {\n const out: Record<string, ConvertedService> = {};\n for (const [name, svc] of Object.entries(spec.services)) {\n out[name] = convertServiceDef(svc);\n }\n return out;\n}\n\n/**\n * Narrow `SingleServiceSpec.port: number | number[] | undefined` to\n * fred's single-service `port: number | undefined`.\n *\n * - `undefined` → `undefined`\n * - `80` → `80`\n * - `[80]` → `80` (single-element array convenience)\n * - `[80, 443, …]` → `ManifestMCPError(INVALID_CONFIG)`\n *\n * Strategy (a) per the ENG-185 sub-PR A brief: rejecting multi-element\n * arrays at the builder boundary kills the prior silent `port[0]`\n * truncation. The error message points the caller at the `StackSpec`\n * escape hatch where multi-port routing is natively expressible.\n */\nfunction narrowSingleServicePort(\n port: number | number[] | undefined,\n): number | undefined {\n if (port === undefined) return undefined;\n if (typeof port === 'number') return port;\n if (Array.isArray(port)) {\n if (port.length === 0) return undefined;\n if (port.length === 1) return port[0];\n throw new ManifestMCPError(\n ManifestMCPErrorCode.INVALID_CONFIG,\n 'SingleServiceSpec.port: multi-port single-service is not supported — use a StackSpec with explicit `services.<name>.ports` to expose multiple ports.',\n );\n }\n // Unreachable under the typed contract; defensive for `as unknown as`\n // callers that bypass the compile-time guard.\n return undefined;\n}\n\n/**\n * Build fred's `BuildManifestPreviewInput` from a typed `DeploySpec`.\n *\n * Stack arm: each `ServiceDef.ports: number[]` is converted to\n * `{<port>/tcp: {}}`. Services without `ports` (or with empty `[]`)\n * omit the `ports` key in the output.\n *\n * Single-service arm: `port: number` and `port: [80]` both produce\n * `port: 80`. Multi-element arrays throw INVALID_CONFIG.\n *\n * Deploy-only fields (`customDomain`, `serviceName`) are deliberately\n * NOT forwarded — the preview path computes only the manifest meta-hash;\n * domain claims happen later in the deploy path.\n *\n * The `size` parameter is accepted for signature parity with\n * `buildFredDeployInput` but is NOT included in the returned object:\n * fred's `BuildManifestPreviewInput` type has no `size` field and\n * `buildManifestPreview` derives no behavior from it. The prior inline\n * builder emitted `size` via an `as unknown as` cast that hid the\n * type-contract violation; this version drops it cleanly.\n */\nexport function buildManifestPreviewInput(\n spec: DeploySpec,\n // Reserved for signature parity with buildFredDeployInput; not consumed.\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n _size: string,\n): BuildManifestPreviewInput {\n if (isStackSpec(spec)) {\n return { services: convertStackServices(spec) };\n }\n const port = narrowSingleServicePort(spec.port);\n return {\n image: spec.image,\n ...(port !== undefined ? { port } : {}),\n ...(spec.env !== undefined ? { env: spec.env } : {}),\n };\n}\n\n/**\n * Build fred's `DeployAppInput` from a typed `DeploySpec`.\n *\n * Same port-shape conversion as `buildManifestPreviewInput` (so the\n * meta-hash recorded on-chain matches the manifest actually uploaded).\n * Additionally threads `customDomain` (both shapes) and `serviceName`\n * (stack only — single-service leases have no service-name to address).\n *\n * @param pin Optional pre-resolved SKU pin (ENG-258). When supplied, spreads\n * `skuUuid` and `providerUuid` into the output so fred's `deployApp` skips\n * the name-based lookup (uses the `resolved` selector path). Existing\n * 2-arg callers are unaffected — the parameter is optional.\n */\nexport function buildFredDeployInput(\n spec: DeploySpec,\n size: string,\n pin?: { skuUuid: string; providerUuid: string },\n): FredDeployAppInput {\n if (isStackSpec(spec)) {\n const out: FredDeployAppInput = {\n size,\n services: convertStackServices(spec),\n ...(pin ? { skuUuid: pin.skuUuid, providerUuid: pin.providerUuid } : {}),\n };\n if (typeof spec.customDomain === 'string' && spec.customDomain.length > 0) {\n out.customDomain = spec.customDomain;\n if (typeof spec.serviceName === 'string' && spec.serviceName.length > 0) {\n out.serviceName = spec.serviceName;\n }\n }\n return out;\n }\n const port = narrowSingleServicePort(spec.port);\n const out: FredDeployAppInput = {\n size,\n image: spec.image,\n ...(pin ? { skuUuid: pin.skuUuid, providerUuid: pin.providerUuid } : {}),\n };\n if (port !== undefined) out.port = port;\n if (spec.env !== undefined) out.env = spec.env;\n if (typeof spec.customDomain === 'string' && spec.customDomain.length > 0) {\n out.customDomain = spec.customDomain;\n }\n return out;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqEA,SAAS,UAAU,OAAuC;CACxD,MAAM,MAAmB,CAAC;CAC1B,KAAK,MAAM,QAAQ,OACjB,IAAI,GAAG,KAAK,SAAS,CAAC;CAExB,OAAO;AACT;;;;;;;AAQA,SAAS,kBAAkB,KAAmC;CAC5D,MAAM,MAAwB,EAAE,OAAO,IAAI,MAAM;CACjD,IAAI,IAAI,UAAU,KAAA,KAAa,IAAI,MAAM,SAAS,GAChD,IAAI,QAAQ,UAAU,IAAI,KAAK;CAEjC,IAAI,IAAI,QAAQ,KAAA,GAAW,IAAI,MAAM,IAAI;CACzC,IAAI,IAAI,SAAS,KAAA,GAAW,IAAI,OAAO,CAAC,GAAG,IAAI,IAAI;CACnD,IAAI,IAAI,YAAY,KAAA,GAAW,IAAI,UAAU,CAAC,GAAG,IAAI,OAAO;CAC5D,OAAO;AACT;AAEA,SAAS,qBACP,MACkC;CAClC,MAAM,MAAwC,CAAC;CAC/C,KAAK,MAAM,CAAC,MAAM,QAAQ,OAAO,QAAQ,KAAK,QAAQ,GACpD,IAAI,QAAQ,kBAAkB,GAAG;CAEnC,OAAO;AACT;;;;;;;;;;;;;;;AAgBA,SAAS,wBACP,MACoB;CACpB,IAAI,SAAS,KAAA,GAAW,OAAO,KAAA;CAC/B,IAAI,OAAO,SAAS,UAAU,OAAO;CACrC,IAAI,MAAM,QAAQ,IAAI,GAAG;EACvB,IAAI,KAAK,WAAW,GAAG,OAAO,KAAA;EAC9B,IAAI,KAAK,WAAW,GAAG,OAAO,KAAK;EACnC,MAAM,IAAI,iBACR,qBAAqB,gBACrB,sJACF;CACF;AAIF;;;;;;;;;;;;;;;;;;;;;;AAuBA,SAAgB,0BACd,MAGA,OAC2B;CAC3B,IAAI,YAAY,IAAI,GAClB,OAAO,EAAE,UAAU,qBAAqB,IAAI,EAAE;CAEhD,MAAM,OAAO,wBAAwB,KAAK,IAAI;CAC9C,OAAO;EACL,OAAO,KAAK;EACZ,GAAI,SAAS,KAAA,IAAY,EAAE,KAAK,IAAI,CAAC;EACrC,GAAI,KAAK,QAAQ,KAAA,IAAY,EAAE,KAAK,KAAK,IAAI,IAAI,CAAC;CACpD;AACF;;;;;;;;;;;;;;AAeA,SAAgB,qBACd,MACA,MACA,KACoB;CACpB,IAAI,YAAY,IAAI,GAAG;EACrB,MAAM,MAA0B;GAC9B;GACA,UAAU,qBAAqB,IAAI;GACnC,GAAI,MAAM;IAAE,SAAS,IAAI;IAAS,cAAc,IAAI;GAAa,IAAI,CAAC;EACxE;EACA,IAAI,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,SAAS,GAAG;GACzE,IAAI,eAAe,KAAK;GACxB,IAAI,OAAO,KAAK,gBAAgB,YAAY,KAAK,YAAY,SAAS,GACpE,IAAI,cAAc,KAAK;EAE3B;EACA,OAAO;CACT;CACA,MAAM,OAAO,wBAAwB,KAAK,IAAI;CAC9C,MAAM,MAA0B;EAC9B;EACA,OAAO,KAAK;EACZ,GAAI,MAAM;GAAE,SAAS,IAAI;GAAS,cAAc,IAAI;EAAa,IAAI,CAAC;CACxE;CACA,IAAI,SAAS,KAAA,GAAW,IAAI,OAAO;CACnC,IAAI,KAAK,QAAQ,KAAA,GAAW,IAAI,MAAM,KAAK;CAC3C,IAAI,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,SAAS,GACtE,IAAI,eAAe,KAAK;CAE1B,OAAO;AACT"}
@@ -1 +1 @@
1
- {"version":3,"file":"evaluate-readiness-from-fred.d.ts","names":[],"sources":["../../src/internals/evaluate-readiness-from-fred.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;iBAuEgB,iCAAA,CACd,GAAA,EAAK,8BAAA,EACL,QAAA,UACA,QAAA,EAAU,QAAA,EACV,aAAA,WACC,SAAA"}
1
+ {"version":3,"file":"evaluate-readiness-from-fred.d.ts","names":[],"sources":["../../src/internals/evaluate-readiness-from-fred.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;iBA0EgB,iCAAA,CACd,GAAA,EAAK,8BAAA,EACL,QAAA,UACA,QAAA,EAAU,QAAA,EACV,aAAA,WACC,SAAA"}
@@ -21,8 +21,9 @@ import { evaluateReadiness } from "./evaluate-readiness.js";
21
21
  * different wallet.
22
22
  */
23
23
  function evaluateReadinessFromFredResponse(raw, gasPrice, denomMap, tenantAddress) {
24
- const skuNames = new Set(raw.available_sku_names);
25
- if (raw.sku !== null) skuNames.add(raw.sku.name);
24
+ const skuNamesSet = new Set((raw.available_skus ?? []).map((s) => s.name));
25
+ if (raw.sku !== null) skuNamesSet.add(raw.sku.name);
26
+ const availableSkuNames = [...skuNamesSet];
26
27
  return evaluateReadiness({
27
28
  tenant: tenantAddress,
28
29
  image: raw.image,
@@ -30,7 +31,15 @@ function evaluateReadinessFromFredResponse(raw, gasPrice, denomMap, tenantAddres
30
31
  walletBalances: toCoinArray(raw.wallet_balances),
31
32
  credits: translateCredits(raw),
32
33
  sku: translateSku(raw.sku),
33
- availableSkuNames: [...skuNames],
34
+ availableSkuNames,
35
+ skuCandidates: Array.isArray(raw.sku_candidates) ? raw.sku_candidates.map((c) => ({
36
+ name: c.name,
37
+ providerUuid: c.provider_uuid,
38
+ ...c.price ? { price: {
39
+ denom: c.price.denom,
40
+ amount: c.price.amount
41
+ } } : {}
42
+ })) : void 0,
34
43
  gasPrice,
35
44
  denomMap
36
45
  });
@@ -1 +1 @@
1
- {"version":3,"file":"evaluate-readiness-from-fred.js","names":[],"sources":["../../src/internals/evaluate-readiness-from-fred.ts"],"sourcesContent":["/**\n * Translator: fred's `CheckDeploymentReadinessResult` (snake_case wire\n * shape) → canonical `evaluateReadiness`'s `EvaluateReadinessInputs`\n * (camelCase + deploy-app context). ENG-185 sub-PR B, item 1.\n *\n * Replaces the always-`'ok'` stub `evaluateReadinessFromRaw` previously\n * inlined in `deploy-app.ts`. With this translator wired in, the\n * `status === 'block'` short-circuit at BOTH call sites\n * (`deploy-app.ts` L207 initial-spec + L327 post-edit recall) fires\n * correctly, killing the silent \"always proceed\" path the stub kept open.\n *\n * Three concerns the translator owns:\n *\n * 1. **Field renames** — snake_case → camelCase across all 9 fred\n * top-level fields the evaluator consumes (`wallet_balances` →\n * `walletBalances`, `available_sku_names` → `availableSkuNames`,\n * `credits.available_balances` → `credits.availableBalances`, etc.).\n *\n * 2. **Folding top-level → nested** — fred's `getBalance` emits\n * `current_balance` and `hours_remaining` ALONGSIDE `credits`\n * (top-level on the response); the evaluator's input nests them\n * INSIDE `credits` as `currentBalance` / `hoursRemaining`. The\n * translator moves them across the boundary.\n *\n * Guard: when fred returns `credits: null`, the translator\n * preserves null — synthesizing a credits object from the stray\n * top-level fields would bypass the evaluator's\n * `credits === null` warn rule (\"No credit account funded for\n * compute leases\").\n *\n * 3. **Context injection** — `gasPrice`, `denomMap`, and `tenant`\n * come from the orchestrator's scope (not from fred). For\n * `tenant`, the translator deliberately IGNORES `raw.tenant` and\n * uses the `tenantAddress` arg: the orchestrator already resolved\n * and validated the canonical wallet/client address via the\n * address-source consistency guard (deploy-app.ts L154-161).\n *\n * Also: drops fred sku fields `uuid` / `provider_uuid` / `active` (the\n * evaluator only needs `name` + `price`), and coerces a price-less\n * SKU to `null` — `EvaluateReadinessInputs.sku` requires `price: Coin`,\n * so an SKU without price is structurally not a valid input.\n */\n\nimport type { CheckDeploymentReadinessResult } from '@manifest-network/manifest-mcp-fred';\nimport type { Coin, Readiness } from '../types.js';\nimport {\n type EvaluateReadinessInputs,\n evaluateReadiness,\n} from './evaluate-readiness.js';\nimport type { DenomMap } from './humanize-denom.js';\n\n/**\n * Translate fred's snake_case `CheckDeploymentReadinessResult` into\n * the canonical `EvaluateReadinessInputs` (camelCase + context) and\n * invoke `evaluateReadiness`. Returns the typed `Readiness` verdict\n * the orchestrator gates on (`status === 'block'` → INVALID_CONFIG).\n *\n * @param raw fred's wire response (snake_case, readonly).\n * @param gasPrice Gas-price string from `clientManager.getConfig().gasPrice`\n * (e.g. `'1umfx'`). Required by the evaluator's\n * wallet-gas check; defaulted upstream when absent.\n * @param denomMap Pre-loaded `DenomMap` for humanization. Pass\n * `EMPTY_DENOM_MAP` when no chain-data file is\n * configured.\n * @param tenantAddress Canonical tenant address from the orchestrator's\n * address-source consistency guard. PREFERRED over\n * `raw.tenant` so a fred response whose `tenant`\n * differs (configuration drift / replayed mock)\n * does NOT silently route the verdict against a\n * different wallet.\n */\nexport function evaluateReadinessFromFredResponse(\n raw: CheckDeploymentReadinessResult,\n gasPrice: string,\n denomMap: DenomMap,\n tenantAddress: string,\n): Readiness {\n // Union `raw.sku.name` into the names list (Copilot #3319670583).\n // Fred caps `available_sku_names` at `MAX_SKU_NAMES_RETURNED = 50`\n // (`packages/fred/src/tools/checkDeploymentReadiness.ts`); when the\n // chain has >50 SKUs and the user's requested size falls past the\n // slice, the evaluator's SKU-availability rule false-blocks even\n // though fred already resolved the SKU into `raw.sku`. Folding\n // `raw.sku.name` into the set closes the gap. Set handles dedupe\n // (`raw.sku.name` may already be in the first-50 list).\n const skuNames = new Set(raw.available_sku_names);\n if (raw.sku !== null) skuNames.add(raw.sku.name);\n\n return evaluateReadiness({\n tenant: tenantAddress,\n image: raw.image,\n size: raw.size,\n walletBalances: toCoinArray(raw.wallet_balances),\n credits: translateCredits(raw),\n sku: translateSku(raw.sku),\n availableSkuNames: [...skuNames],\n gasPrice,\n denomMap,\n });\n}\n\n/**\n * Translate fred's `credits` object + top-level `current_balance` /\n * `hours_remaining` into the evaluator's nested `credits` input shape.\n *\n * Null preservation: when fred returns `credits: null`, the translator\n * returns null even if `current_balance` / `hours_remaining` are\n * present at the top level — synthesizing a credits object from the\n * stray fields would suppress the \"no credit account funded\" warn\n * rule the evaluator owns.\n */\nfunction translateCredits(\n raw: CheckDeploymentReadinessResult,\n): EvaluateReadinessInputs['credits'] {\n if (raw.credits === null) return null;\n // Defensive emission: only write a field when fred actually supplied\n // it. Fred's `CheckDeploymentReadinessResult` declares `balances` /\n // `available_balances` as required, but the evaluator's input shape\n // accepts both as OPTIONAL — and skipping the field on absent input\n // is preferable to surfacing a `.map of undefined` crash if a mock or\n // upstream variant elides the field. The evaluator's source-of-truth\n // precedence (availableBalances → balances → currentBalance → []) is\n // already CJS-parity-correct for partial credits objects.\n const out: NonNullable<EvaluateReadinessInputs['credits']> = {};\n if (Array.isArray(raw.credits.available_balances)) {\n out.availableBalances = toCoinArray(raw.credits.available_balances);\n }\n if (Array.isArray(raw.credits.balances)) {\n out.balances = toCoinArray(raw.credits.balances);\n }\n if (raw.current_balance !== undefined) {\n out.currentBalance = toCoinArray(raw.current_balance);\n }\n if (raw.hours_remaining !== undefined) {\n out.hoursRemaining = raw.hours_remaining;\n }\n return out;\n}\n\n/**\n * Translate fred's `SkuSummary | null` into the evaluator's\n * `{ name: string; price: Coin } | null`.\n *\n * Drops fred-only fields (`uuid`, `provider_uuid`, `active`). Coerces\n * a price-less SKU to `null` — the evaluator's `sku.price` is required\n * (`Coin`), so an SKU without price is structurally invalid input.\n * Without this coercion the evaluator would treat the SKU as truthy\n * and crash accessing `price.amount`.\n */\nfunction translateSku(\n sku: CheckDeploymentReadinessResult['sku'],\n): EvaluateReadinessInputs['sku'] {\n if (sku === null) return null;\n if (sku.price === undefined) return null;\n return {\n name: sku.name,\n price: { denom: sku.price.denom, amount: sku.price.amount },\n };\n}\n\n/**\n * Spread a readonly Coin-shaped array into a mutable Coin[]. The\n * evaluator's `EvaluateReadinessInputs.walletBalances` / credit-balance\n * arrays are mutable; fred's wire shapes are `ReadonlyArray<...>`. A\n * shallow copy is sufficient — each element is a frozen-ish `{denom,\n * amount}` value tuple, never mutated by the evaluator.\n */\nfunction toCoinArray(\n arr: ReadonlyArray<{ readonly denom: string; readonly amount: string }>,\n): Coin[] {\n return arr.map((c) => ({ denom: c.denom, amount: c.amount }));\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAuEA,SAAgB,kCACd,KACA,UACA,UACA,eACW;CASX,MAAM,WAAW,IAAI,IAAI,IAAI,mBAAmB;CAChD,IAAI,IAAI,QAAQ,MAAM,SAAS,IAAI,IAAI,IAAI,IAAI;CAE/C,OAAO,kBAAkB;EACvB,QAAQ;EACR,OAAO,IAAI;EACX,MAAM,IAAI;EACV,gBAAgB,YAAY,IAAI,eAAe;EAC/C,SAAS,iBAAiB,GAAG;EAC7B,KAAK,aAAa,IAAI,GAAG;EACzB,mBAAmB,CAAC,GAAG,QAAQ;EAC/B;EACA;CACF,CAAC;AACH;;;;;;;;;;;AAYA,SAAS,iBACP,KACoC;CACpC,IAAI,IAAI,YAAY,MAAM,OAAO;CASjC,MAAM,MAAuD,CAAC;CAC9D,IAAI,MAAM,QAAQ,IAAI,QAAQ,kBAAkB,GAC9C,IAAI,oBAAoB,YAAY,IAAI,QAAQ,kBAAkB;CAEpE,IAAI,MAAM,QAAQ,IAAI,QAAQ,QAAQ,GACpC,IAAI,WAAW,YAAY,IAAI,QAAQ,QAAQ;CAEjD,IAAI,IAAI,oBAAoB,KAAA,GAC1B,IAAI,iBAAiB,YAAY,IAAI,eAAe;CAEtD,IAAI,IAAI,oBAAoB,KAAA,GAC1B,IAAI,iBAAiB,IAAI;CAE3B,OAAO;AACT;;;;;;;;;;;AAYA,SAAS,aACP,KACgC;CAChC,IAAI,QAAQ,MAAM,OAAO;CACzB,IAAI,IAAI,UAAU,KAAA,GAAW,OAAO;CACpC,OAAO;EACL,MAAM,IAAI;EACV,OAAO;GAAE,OAAO,IAAI,MAAM;GAAO,QAAQ,IAAI,MAAM;EAAO;CAC5D;AACF;;;;;;;;AASA,SAAS,YACP,KACQ;CACR,OAAO,IAAI,KAAK,OAAO;EAAE,OAAO,EAAE;EAAO,QAAQ,EAAE;CAAO,EAAE;AAC9D"}
1
+ {"version":3,"file":"evaluate-readiness-from-fred.js","names":[],"sources":["../../src/internals/evaluate-readiness-from-fred.ts"],"sourcesContent":["/**\n * Translator: fred's `CheckDeploymentReadinessResult` (snake_case wire\n * shape) → canonical `evaluateReadiness`'s `EvaluateReadinessInputs`\n * (camelCase + deploy-app context). ENG-185 sub-PR B, item 1.\n *\n * Replaces the always-`'ok'` stub `evaluateReadinessFromRaw` previously\n * inlined in `deploy-app.ts`. With this translator wired in, the\n * `status === 'block'` short-circuit at BOTH call sites\n * (`deploy-app.ts` L207 initial-spec + L327 post-edit recall) fires\n * correctly, killing the silent \"always proceed\" path the stub kept open.\n *\n * Three concerns the translator owns:\n *\n * 1. **Field renames** — snake_case → camelCase across all fred\n * top-level fields the evaluator consumes (`wallet_balances` →\n * `walletBalances`; `credits.available_balances` →\n * `credits.availableBalances`, etc.). ENG-258 (Task 15) removed\n * the `available_sku_names` field; `availableSkuNames` is now\n * derived from `available_skus` and `sku_candidates` maps to\n * `skuCandidates` for the candidate-based gate.\n *\n * 2. **Folding top-level → nested** — fred's `getBalance` emits\n * `current_balance` and `hours_remaining` ALONGSIDE `credits`\n * (top-level on the response); the evaluator's input nests them\n * INSIDE `credits` as `currentBalance` / `hoursRemaining`. The\n * translator moves them across the boundary.\n *\n * Guard: when fred returns `credits: null`, the translator\n * preserves null — synthesizing a credits object from the stray\n * top-level fields would bypass the evaluator's\n * `credits === null` warn rule (\"No credit account funded for\n * compute leases\").\n *\n * 3. **Context injection** — `gasPrice`, `denomMap`, and `tenant`\n * come from the orchestrator's scope (not from fred). For\n * `tenant`, the translator deliberately IGNORES `raw.tenant` and\n * uses the `tenantAddress` arg: the orchestrator already resolved\n * and validated the canonical wallet/client address via the\n * address-source consistency guard (deploy-app.ts L154-161).\n *\n * Also: drops fred sku fields `uuid` / `provider_uuid` / `active` (the\n * evaluator only needs `name` + `price`), and coerces a price-less\n * SKU to `null` — `EvaluateReadinessInputs.sku` requires `price: Coin`,\n * so an SKU without price is structurally not a valid input.\n */\n\nimport type { CheckDeploymentReadinessResult } from '@manifest-network/manifest-mcp-fred';\nimport type { Coin, Readiness } from '../types.js';\nimport {\n type EvaluateReadinessInputs,\n evaluateReadiness,\n} from './evaluate-readiness.js';\nimport type { DenomMap } from './humanize-denom.js';\n\n/**\n * Translate fred's snake_case `CheckDeploymentReadinessResult` into\n * the canonical `EvaluateReadinessInputs` (camelCase + context) and\n * invoke `evaluateReadiness`. Returns the typed `Readiness` verdict\n * the orchestrator gates on (`status === 'block'` → INVALID_CONFIG).\n *\n * @param raw fred's wire response (snake_case, readonly).\n * @param gasPrice Gas-price string from `clientManager.getConfig().gasPrice`\n * (e.g. `'1umfx'`). Required by the evaluator's\n * wallet-gas check; defaulted upstream when absent.\n * @param denomMap Pre-loaded `DenomMap` for humanization. Pass\n * `EMPTY_DENOM_MAP` when no chain-data file is\n * configured.\n * @param tenantAddress Canonical tenant address from the orchestrator's\n * address-source consistency guard. PREFERRED over\n * `raw.tenant` so a fred response whose `tenant`\n * differs (configuration drift / replayed mock)\n * does NOT silently route the verdict against a\n * different wallet.\n */\nexport function evaluateReadinessFromFredResponse(\n raw: CheckDeploymentReadinessResult,\n gasPrice: string,\n denomMap: DenomMap,\n tenantAddress: string,\n): Readiness {\n // Names are only a fallback hint now; derive them from the structured list.\n // ENG-258 Task 15 removed the old flat names field — available_skus is\n // the source of truth now. When raw.sku is resolved (single candidate), fold\n // its name in too (closes the >MAX_SKU_NAMES_RETURNED gap — Copilot #3319670583).\n const skuNamesSet = new Set((raw.available_skus ?? []).map((s) => s.name));\n if (raw.sku !== null) skuNamesSet.add(raw.sku.name);\n const availableSkuNames = [...skuNamesSet];\n\n // Note: `requestedProviderUuid` is intentionally NOT set here. The fred\n // readiness response is already provider-narrowed — `deploy-app.ts` calls\n // `checkDeploymentReadiness` with the pinned `providerUuid`, so\n // `sku_candidates` in the response already reflect only that provider.\n // `requestedProviderUuid` is therefore only needed by direct callers of\n // `evaluateReadiness` that have NOT pre-filtered by provider (e.g. the\n // initial SKU-resolution pass in deploy-app.ts); it would be redundant and\n // misleading on this translated path.\n return evaluateReadiness({\n tenant: tenantAddress,\n image: raw.image,\n size: raw.size,\n walletBalances: toCoinArray(raw.wallet_balances),\n credits: translateCredits(raw),\n sku: translateSku(raw.sku),\n availableSkuNames,\n skuCandidates: Array.isArray(raw.sku_candidates)\n ? raw.sku_candidates.map((c) => ({\n name: c.name,\n providerUuid: c.provider_uuid,\n ...(c.price\n ? { price: { denom: c.price.denom, amount: c.price.amount } }\n : {}),\n }))\n : undefined,\n gasPrice,\n denomMap,\n });\n}\n\n/**\n * Translate fred's `credits` object + top-level `current_balance` /\n * `hours_remaining` into the evaluator's nested `credits` input shape.\n *\n * Null preservation: when fred returns `credits: null`, the translator\n * returns null even if `current_balance` / `hours_remaining` are\n * present at the top level — synthesizing a credits object from the\n * stray fields would suppress the \"no credit account funded\" warn\n * rule the evaluator owns.\n */\nfunction translateCredits(\n raw: CheckDeploymentReadinessResult,\n): EvaluateReadinessInputs['credits'] {\n if (raw.credits === null) return null;\n // Defensive emission: only write a field when fred actually supplied\n // it. Fred's `CheckDeploymentReadinessResult` declares `balances` /\n // `available_balances` as required, but the evaluator's input shape\n // accepts both as OPTIONAL — and skipping the field on absent input\n // is preferable to surfacing a `.map of undefined` crash if a mock or\n // upstream variant elides the field. The evaluator's source-of-truth\n // precedence (availableBalances → balances → currentBalance → []) is\n // already CJS-parity-correct for partial credits objects.\n const out: NonNullable<EvaluateReadinessInputs['credits']> = {};\n if (Array.isArray(raw.credits.available_balances)) {\n out.availableBalances = toCoinArray(raw.credits.available_balances);\n }\n if (Array.isArray(raw.credits.balances)) {\n out.balances = toCoinArray(raw.credits.balances);\n }\n if (raw.current_balance !== undefined) {\n out.currentBalance = toCoinArray(raw.current_balance);\n }\n if (raw.hours_remaining !== undefined) {\n out.hoursRemaining = raw.hours_remaining;\n }\n return out;\n}\n\n/**\n * Translate fred's `SkuSummary | null` into the evaluator's\n * `{ name: string; price: Coin } | null`.\n *\n * Drops fred-only fields (`uuid`, `provider_uuid`, `active`). Coerces\n * a price-less SKU to `null` — the evaluator's `sku.price` is required\n * (`Coin`), so an SKU without price is structurally invalid input.\n * Without this coercion the evaluator would treat the SKU as truthy\n * and crash accessing `price.amount`.\n */\nfunction translateSku(\n sku: CheckDeploymentReadinessResult['sku'],\n): EvaluateReadinessInputs['sku'] {\n if (sku === null) return null;\n if (sku.price === undefined) return null;\n return {\n name: sku.name,\n price: { denom: sku.price.denom, amount: sku.price.amount },\n };\n}\n\n/**\n * Spread a readonly Coin-shaped array into a mutable Coin[]. The\n * evaluator's `EvaluateReadinessInputs.walletBalances` / credit-balance\n * arrays are mutable; fred's wire shapes are `ReadonlyArray<...>`. A\n * shallow copy is sufficient — each element is a frozen-ish `{denom,\n * amount}` value tuple, never mutated by the evaluator.\n */\nfunction toCoinArray(\n arr: ReadonlyArray<{ readonly denom: string; readonly amount: string }>,\n): Coin[] {\n return arr.map((c) => ({ denom: c.denom, amount: c.amount }));\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AA0EA,SAAgB,kCACd,KACA,UACA,UACA,eACW;CAKX,MAAM,cAAc,IAAI,KAAK,IAAI,kBAAkB,CAAC,GAAG,KAAK,MAAM,EAAE,IAAI,CAAC;CACzE,IAAI,IAAI,QAAQ,MAAM,YAAY,IAAI,IAAI,IAAI,IAAI;CAClD,MAAM,oBAAoB,CAAC,GAAG,WAAW;CAUzC,OAAO,kBAAkB;EACvB,QAAQ;EACR,OAAO,IAAI;EACX,MAAM,IAAI;EACV,gBAAgB,YAAY,IAAI,eAAe;EAC/C,SAAS,iBAAiB,GAAG;EAC7B,KAAK,aAAa,IAAI,GAAG;EACzB;EACA,eAAe,MAAM,QAAQ,IAAI,cAAc,IAC3C,IAAI,eAAe,KAAK,OAAO;GAC7B,MAAM,EAAE;GACR,cAAc,EAAE;GAChB,GAAI,EAAE,QACF,EAAE,OAAO;IAAE,OAAO,EAAE,MAAM;IAAO,QAAQ,EAAE,MAAM;GAAO,EAAE,IAC1D,CAAC;EACP,EAAE,IACF,KAAA;EACJ;EACA;CACF,CAAC;AACH;;;;;;;;;;;AAYA,SAAS,iBACP,KACoC;CACpC,IAAI,IAAI,YAAY,MAAM,OAAO;CASjC,MAAM,MAAuD,CAAC;CAC9D,IAAI,MAAM,QAAQ,IAAI,QAAQ,kBAAkB,GAC9C,IAAI,oBAAoB,YAAY,IAAI,QAAQ,kBAAkB;CAEpE,IAAI,MAAM,QAAQ,IAAI,QAAQ,QAAQ,GACpC,IAAI,WAAW,YAAY,IAAI,QAAQ,QAAQ;CAEjD,IAAI,IAAI,oBAAoB,KAAA,GAC1B,IAAI,iBAAiB,YAAY,IAAI,eAAe;CAEtD,IAAI,IAAI,oBAAoB,KAAA,GAC1B,IAAI,iBAAiB,IAAI;CAE3B,OAAO;AACT;;;;;;;;;;;AAYA,SAAS,aACP,KACgC;CAChC,IAAI,QAAQ,MAAM,OAAO;CACzB,IAAI,IAAI,UAAU,KAAA,GAAW,OAAO;CACpC,OAAO;EACL,MAAM,IAAI;EACV,OAAO;GAAE,OAAO,IAAI,MAAM;GAAO,QAAQ,IAAI,MAAM;EAAO;CAC5D;AACF;;;;;;;;AASA,SAAS,YACP,KACQ;CACR,OAAO,IAAI,KAAK,OAAO;EAAE,OAAO,EAAE;EAAO,QAAQ,EAAE;CAAO,EAAE;AAC9D"}
@@ -27,6 +27,20 @@ interface EvaluateReadinessInputs {
27
27
  } | null;
28
28
  /** All active SKU names the chain currently advertises. */
29
29
  availableSkuNames: string[];
30
+ /**
31
+ * Structured candidates for `size` (name + provider). When present, this is
32
+ * preferred over `availableSkuNames` for the SKU-availability gate (ENG-258).
33
+ */
34
+ skuCandidates?: {
35
+ name: string;
36
+ providerUuid: string;
37
+ price?: Coin;
38
+ }[];
39
+ /**
40
+ * Provider the caller pinned (if any). When `skuCandidates` is set, the
41
+ * gate requires at least one candidate whose `providerUuid` matches (ENG-258).
42
+ */
43
+ requestedProviderUuid?: string;
30
44
  /** Gas-price string (e.g. `'1umfx'`, `'0.37upwr'`). Required — drives the wallet-gas check denom. */
31
45
  gasPrice: string;
32
46
  /** Override the per-denom warn floor (smallest unit). When omitted, uses the per-denom default or 50_000n fallback. */
@@ -1 +1 @@
1
- {"version":3,"file":"evaluate-readiness.d.ts","names":[],"sources":["../../src/internals/evaluate-readiness.ts"],"mappings":";;;;AAwDA;;UAAiB,uBAAA;EAQC;EANhB,MAAA;EAWa;EATb,KAAA;EAgB4B;EAd5B,IAAA;EA+BmB;EA7BnB,cAAA,EAAgB,IAAA;EANhB;EAQA,OAAA;IACE,iBAAA,GAAoB,IAAA,IAHtB;IAKE,QAAA,GAAW,IAAA,IAHb;IAKE,cAAA,GAAiB,IAAA,IAJG;IAMpB,cAAA;EAAA;EAFA;EAKF,GAAA;IAAO,IAAA;IAAc,KAAA,EAAO,IAAA;EAAA;EAAP;EAErB,iBAAA;EAAA;EAEA,QAAA;EAEA;EAAA,YAAA;EAWW;;AAAQ;AASrB;;;;;;;EATE,QAAA,GAAW,QAAA;AAAA;;;;;;;iBASG,iBAAA,CAAkB,MAAA,EAAQ,uBAAA,GAA0B,SAAS"}
1
+ {"version":3,"file":"evaluate-readiness.d.ts","names":[],"sources":["../../src/internals/evaluate-readiness.ts"],"mappings":";;;;AA0DA;;UAAiB,uBAAA;EAQC;EANhB,MAAA;EAWa;EATb,KAAA;EAgB4B;EAd5B,IAAA;EAyCW;EAvCX,cAAA,EAAgB,IAAA;EAuCG;EArCnB,OAAA;IACE,iBAAA,GAAoB,IAAA,IALtB;IAOE,QAAA,GAAW,IAAA,IALG;IAOd,cAAA,GAAiB,IAAA,IAJjB;IAMA,cAAA;EAAA;EAJW;EAOb,GAAA;IAAO,IAAA;IAAc,KAAA,EAAO,IAAA;EAAA;EAArB;EAEP,iBAAA;EAF4B;;;;EAO5B,aAAA;IAAkB,IAAA;IAAc,YAAA;IAAsB,KAAA,GAAQ,IAAA;EAAA;EAS9D;;;;EAJA,qBAAA;EAwBc;EAtBd,QAAA;;EAEA,YAAA;EAoBwC;;;;AAAmC;;;;;;EAT3E,QAAA,GAAW,QAAA;AAAA;;;;;;;iBASG,iBAAA,CAAkB,MAAA,EAAQ,uBAAA,GAA0B,SAAS"}
@@ -20,9 +20,11 @@ import { EMPTY_DENOM_MAP, denomToSymbol, humanizeCoin } from "./humanize-denom.j
20
20
  *
21
21
  * Walked-from-CJS field-rename: the MCP response uses snake_case
22
22
  * (`wallet_balances`, `available_balances`, `hours_remaining`,
23
- * `available_sku_names`, `current_balance`); the TS-port input is
24
- * camelCase, and high-level callers (PR 3's `deployApp`) translate the
25
- * snake_case wire shape into camelCase before passing in.
23
+ * `current_balance`); the TS-port input is camelCase, and high-level
24
+ * callers (PR 3's `deployApp`) translate the snake_case wire shape into
25
+ * camelCase before passing in. ENG-258 Task 15 replaced the old flat
26
+ * names field with `skuCandidates` / `availableSkuNames` derived from
27
+ * `available_skus`.
26
28
  */
27
29
  const HOURS_REMAINING_WARN_FLOOR = 24;
28
30
  const GAS_BALANCE_WARN_FLOOR_DEFAULTS = {
@@ -52,11 +54,22 @@ function evaluateReadiness(inputs) {
52
54
  const reasons = [];
53
55
  const actions = /* @__PURE__ */ new Set();
54
56
  let status = "ok";
55
- if (inputs.size !== null && !inputs.availableSkuNames.includes(inputs.size)) {
56
- status = "block";
57
- const available = inputs.availableSkuNames.length > 0 ? inputs.availableSkuNames.join(", ") : "(none)";
58
- reasons.push(`Requested SKU "${inputs.size}" is not currently offered. Available: ${available}.`);
59
- actions.add("pick_different_sku");
57
+ if (inputs.size !== null) {
58
+ const candidates = inputs.skuCandidates;
59
+ let available;
60
+ if (candidates !== void 0) available = candidates.some((c) => c.name === inputs.size && (inputs.requestedProviderUuid === void 0 || c.providerUuid === inputs.requestedProviderUuid));
61
+ else available = inputs.availableSkuNames.includes(inputs.size);
62
+ if (!available) {
63
+ status = "block";
64
+ if (candidates !== void 0) {
65
+ const hint = inputs.requestedProviderUuid ? ` on provider ${inputs.requestedProviderUuid}` : "";
66
+ reasons.push(`Requested SKU "${inputs.size}"${hint} is not currently offered.`);
67
+ } else {
68
+ const availableDisplay = inputs.availableSkuNames.length > 0 ? inputs.availableSkuNames.join(", ") : "(none)";
69
+ reasons.push(`Requested SKU "${inputs.size}" is not currently offered. Available: ${availableDisplay}.`);
70
+ }
71
+ actions.add("pick_different_sku");
72
+ }
60
73
  }
61
74
  const gasEntry = inputs.walletBalances.find((b) => b.denom === gasDenom);
62
75
  const gasAmount = gasEntry ? asBigInt(gasEntry.amount) : 0n;
@@ -1 +1 @@
1
- {"version":3,"file":"evaluate-readiness.js","names":[],"sources":["../../src/internals/evaluate-readiness.ts"],"sourcesContent":["import type { Coin, Readiness, ReadinessAction } from '../types.js';\nimport {\n type DenomMap,\n denomToSymbol,\n EMPTY_DENOM_MAP,\n humanizeCoin,\n} from './humanize-denom.js';\n\n/**\n * Evaluate `check_deployment_readiness` MCP response data into the frozen\n * `Readiness` shape (camelCase typed input + the `Readiness` contract from\n * ENG-128).\n *\n * Thresholds are encoded here (not in skill prose or caller config) so the\n * rules stay consistent across runs:\n * - HOURS_REMAINING_WARN_FLOOR = 24\n * - GAS_BALANCE_WARN_FLOOR (per-denom) = 50_000n umfx | upwr\n *\n * Status semantics (CJS-parity):\n * - `'block'` — cannot proceed (SKU unavailable, wallet empty)\n * - `'warn'` — proceedable but risky (low credits, low gas balance, no credit account)\n * - `'ok'` — silent pass\n *\n * `suggestedActions` are semantic tokens from the frozen `ReadinessAction`\n * union — not prose for the user. Surfaces map these to UI affordances.\n *\n * Walked-from-CJS field-rename: the MCP response uses snake_case\n * (`wallet_balances`, `available_balances`, `hours_remaining`,\n * `available_sku_names`, `current_balance`); the TS-port input is\n * camelCase, and high-level callers (PR 3's `deployApp`) translate the\n * snake_case wire shape into camelCase before passing in.\n */\n\nconst HOURS_REMAINING_WARN_FLOOR = 24;\n\n// Per-denom warn floors for low gas balance (in smallest unit). Mirrors the\n// CJS values: 50_000 umfx = 0.05 MFX (1 MFX = 1,000,000 umfx); comparable\n// headroom for upwr.\nconst GAS_BALANCE_WARN_FLOOR_DEFAULTS: Readonly<Record<string, bigint>> = {\n umfx: 50_000n,\n upwr: 50_000n,\n};\nconst GAS_BALANCE_WARN_FLOOR_FALLBACK = 50_000n;\n\n/**\n * Cosmos convention for gas-price strings: leading numeric (digits +\n * optional decimal point), then the denom. Denom grammar mirrors\n * `sdk.ValidateDenom`: `[a-zA-Z][a-zA-Z0-9/:._-]{2,127}`.\n * Anchored both ends so trailing whitespace fails fast.\n */\nconst GAS_PRICE_RE = /^[0-9]+(?:\\.[0-9]+)?([a-zA-Z][a-zA-Z0-9/:._-]{2,127})$/;\n\n/**\n * Inputs passed to `evaluateReadiness`. camelCase throughout — high-level\n * callers translate the snake_case MCP response shape before invocation.\n */\nexport interface EvaluateReadinessInputs {\n /** Tenant address (bech32). Not consumed by the algorithm; included for journal/log context. */\n tenant: string;\n /** Image ref being considered (may be `null` when only the size is selected). */\n image: string | null;\n /** SKU size string the caller wants (`'docker-micro'`, etc.). `null` when not yet chosen. */\n size: string | null;\n /** Wallet bank balances. */\n walletBalances: Coin[];\n /** Credit account data, or `null` when no credit account is funded. */\n credits: {\n availableBalances?: Coin[];\n /** Older response variant — fallback when `availableBalances` is absent. */\n balances?: Coin[];\n /** Live current credit balance(s) when the tenant has at least one active lease. */\n currentBalance?: Coin[];\n /** Hours of runtime at the user's current overall burn rate (string-encoded number). */\n hoursRemaining?: string;\n } | null;\n /** Chosen SKU + price, or `null` when no size selected. */\n sku: { name: string; price: Coin } | null;\n /** All active SKU names the chain currently advertises. */\n availableSkuNames: string[];\n /** Gas-price string (e.g. `'1umfx'`, `'0.37upwr'`). Required — drives the wallet-gas check denom. */\n gasPrice: string;\n /** Override the per-denom warn floor (smallest unit). When omitted, uses the per-denom default or 50_000n fallback. */\n gasWarnFloor?: bigint;\n /**\n * Pre-loaded `DenomMap` for symbol humanization. The orchestrator\n * (`deploy-app.ts` and PR-4 callers) is responsible for composing the\n * map via `loadChainDenomMap(chainDataFile)` and passing it in. This\n * keeps `evaluateReadiness` pure-sync — I/O lives at the orchestrator\n * boundary, not inside the decision function (post-Q4 Bii verdict).\n *\n * When omitted, the no-op map is used; balances + SKU prices render\n * with raw on-chain denoms.\n */\n denomMap?: DenomMap;\n}\n\n/**\n * Compute the `Readiness` verdict for a prospective deployment.\n *\n * Throws `TypeError` on malformed `gasPrice` (the only input field whose\n * runtime shape isn't enforced by the typed signature).\n */\nexport function evaluateReadiness(inputs: EvaluateReadinessInputs): Readiness {\n // --- Parse + validate gasPrice via String.match (avoids RegExp.exec) ---\n const gasDenomMatch = inputs.gasPrice.match(GAS_PRICE_RE);\n if (!gasDenomMatch || gasDenomMatch[1] === undefined) {\n throw new TypeError(\n `evaluateReadiness: gasPrice must match <numeric><denom> (e.g. \"1umfx\" or \"0.37upwr\"); got \"${inputs.gasPrice}\"`,\n );\n }\n const gasDenom = gasDenomMatch[1];\n\n // --- Resolve gas warn floor ---\n const gasWarnFloor =\n inputs.gasWarnFloor !== undefined\n ? validateGasWarnFloor(inputs.gasWarnFloor)\n : (GAS_BALANCE_WARN_FLOOR_DEFAULTS[gasDenom] ??\n GAS_BALANCE_WARN_FLOOR_FALLBACK);\n\n // --- Resolve denom map ---\n // Pure-sync decision function: callers pre-load the map via\n // `loadChainDenomMap(chainDataFile)` and pass it in. When absent, the\n // empty no-op map is used (raw on-chain denom rendering downstream).\n // See Q4 Bii rationale in EvaluateReadinessInputs.denomMap docstring.\n const denomMap = inputs.denomMap ?? EMPTY_DENOM_MAP;\n\n // --- Walk readiness rules ---\n const reasons: string[] = [];\n const actions = new Set<ReadinessAction>();\n let status: Readiness['status'] = 'ok';\n\n // 1. SKU availability — hard block when the user's chosen size isn't offered.\n if (inputs.size !== null && !inputs.availableSkuNames.includes(inputs.size)) {\n status = 'block';\n const available =\n inputs.availableSkuNames.length > 0\n ? inputs.availableSkuNames.join(', ')\n : '(none)';\n reasons.push(\n `Requested SKU \"${inputs.size}\" is not currently offered. Available: ${available}.`,\n );\n actions.add('pick_different_sku');\n }\n\n // 2. Wallet gas balance — hard block on absent/zero, warn on below-floor.\n const gasEntry = inputs.walletBalances.find((b) => b.denom === gasDenom);\n const gasAmount = gasEntry ? asBigInt(gasEntry.amount) : 0n;\n if (inputs.walletBalances.length === 0 || gasAmount === 0n) {\n status = 'block';\n reasons.push(\n `Wallet has no ${denomToSymbol(gasDenom, denomMap)} balance for gas.`,\n );\n actions.add('request_faucet');\n actions.add('topup_wallet');\n } else if (gasAmount < gasWarnFloor) {\n if (status === 'ok') status = 'warn';\n reasons.push(\n `Wallet balance (${humanizeCoin(\n gasAmount.toString(),\n gasDenom,\n denomMap,\n )}) is below ${humanizeCoin(\n gasWarnFloor.toString(),\n gasDenom,\n denomMap,\n )}; broadcast may run out of gas.`,\n );\n actions.add('topup_wallet');\n }\n\n // 3. Credits.\n //\n // CJS preserves a subtle source-of-truth selection: `credits.availableBalances`\n // is the \"right now\" balance net of pending reservations; `credits.balances`\n // (older variant) is the gross-funded fallback; `currentBalance` is from\n // the chain's credit estimator and is only present when the tenant has at\n // least one ACTIVE lease. A fresh deployer with credits but no active\n // leases would have `currentBalance` ABSENT — reading that field FIRST as\n // the credit source produces a false \"Credit account is empty\" warning.\n // Mirror the CJS precedence: availableBalances → balances → currentBalance.\n const credits = inputs.credits;\n if (credits === null) {\n if (status === 'ok') status = 'warn';\n reasons.push('No credit account funded for compute leases.');\n actions.add('fund_credit');\n } else if (\n inputs.sku !== null &&\n inputs.sku.price.amount.length > 0 &&\n inputs.sku.price.denom.length > 0\n ) {\n const skuPrice = inputs.sku.price;\n const creditBalances: Coin[] = Array.isArray(credits.availableBalances)\n ? credits.availableBalances\n : Array.isArray(credits.balances)\n ? credits.balances\n : Array.isArray(credits.currentBalance)\n ? credits.currentBalance\n : [];\n const creditEntry = creditBalances.find((b) => b.denom === skuPrice.denom);\n const pricePerHour = asBigInt(skuPrice.amount);\n if (creditEntry === undefined) {\n // The credit account has NO entry in the SKU's price denom. Distinct\n // from \"credits ran out\" — usually means credits are funded in a\n // different denom than the SKU charges in. Emit a specific\n // diagnostic so the user knows to fund_credit in the right denom\n // rather than seeing a false \"0 hours of runtime\" warning.\n const fundedDenoms = creditBalances\n .map((b) => b.denom)\n .filter((d): d is string => typeof d === 'string' && d.length > 0);\n const skuSymbol = denomToSymbol(skuPrice.denom, denomMap);\n const fundedSymbols = fundedDenoms.map((d) => denomToSymbol(d, denomMap));\n if (status === 'ok') status = 'warn';\n reasons.push(\n fundedDenoms.length > 0\n ? `Credit account has no ${skuSymbol} balance (the ${inputs.sku.name} SKU charges in ${skuSymbol}; account holds ${fundedSymbols.join(\n ', ',\n )}). Fund ${skuSymbol} credits before deploying.`\n : `Credit account is empty for the ${inputs.sku.name} SKU's ${skuSymbol} denom. Fund ${skuSymbol} credits before deploying.`,\n );\n actions.add('fund_credit');\n } else if (pricePerHour > 0n) {\n const creditAmount = asBigInt(creditEntry.amount);\n // Convert via Number for the human-readable hours figure. Credit\n // amounts are in the chain's smallest unit and bounded well below\n // Number.MAX_SAFE_INTEGER for any realistic balance.\n const hrsForThisSku = Number(creditAmount) / Number(pricePerHour);\n if (hrsForThisSku < HOURS_REMAINING_WARN_FLOOR) {\n if (status === 'ok') status = 'warn';\n reasons.push(\n `Credits cover ~${hrsForThisSku.toFixed(1)}h of runtime at the ${inputs.sku.name} SKU (${humanizeCoin(\n creditAmount.toString(),\n skuPrice.denom,\n denomMap,\n )} / ${humanizeCoin(\n pricePerHour.toString(),\n skuPrice.denom,\n denomMap,\n )} per hour); below the ${HOURS_REMAINING_WARN_FLOOR}h floor.`,\n );\n actions.add('fund_credit');\n }\n }\n } else if (credits.hoursRemaining !== undefined) {\n // Fallback for cases where SKU pricing is not available (e.g. caller\n // didn't pass --size). Use the chain's hoursRemaining but ONLY warn\n // when it's a meaningful positive number below the floor — `0` here\n // means \"no current burn\", not \"low credits\".\n const hrs = Number(credits.hoursRemaining);\n if (Number.isFinite(hrs) && hrs > 0 && hrs < HOURS_REMAINING_WARN_FLOOR) {\n if (status === 'ok') status = 'warn';\n reasons.push(\n `Credits cover ~${hrs.toFixed(1)}h of runtime at the current burn rate; below the ${HOURS_REMAINING_WARN_FLOOR}h floor.`,\n );\n actions.add('fund_credit');\n }\n }\n\n // --- Map input shape into the frozen `Readiness` carrier fields ---\n const creditsOut: Readiness['credits'] =\n credits === null\n ? null\n : {\n availableBalances: Array.isArray(credits.availableBalances)\n ? credits.availableBalances\n : Array.isArray(credits.balances)\n ? credits.balances\n : Array.isArray(credits.currentBalance)\n ? credits.currentBalance\n : [],\n };\n\n return {\n status,\n reasons,\n suggestedActions: Array.from(actions),\n walletBalances: inputs.walletBalances,\n credits: creditsOut,\n sku: inputs.sku,\n };\n}\n\nfunction asBigInt(s: string): bigint {\n try {\n return BigInt(s);\n } catch {\n return 0n;\n }\n}\n\nfunction validateGasWarnFloor(value: bigint): bigint {\n if (value < 0n) {\n throw new TypeError(\n `evaluateReadiness: gasWarnFloor must be a non-negative integer, got ${value}`,\n );\n }\n return value;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"evaluate-readiness.js","names":[],"sources":["../../src/internals/evaluate-readiness.ts"],"sourcesContent":["import type { Coin, Readiness, ReadinessAction } from '../types.js';\nimport {\n type DenomMap,\n denomToSymbol,\n EMPTY_DENOM_MAP,\n humanizeCoin,\n} from './humanize-denom.js';\n\n/**\n * Evaluate `check_deployment_readiness` MCP response data into the frozen\n * `Readiness` shape (camelCase typed input + the `Readiness` contract from\n * ENG-128).\n *\n * Thresholds are encoded here (not in skill prose or caller config) so the\n * rules stay consistent across runs:\n * - HOURS_REMAINING_WARN_FLOOR = 24\n * - GAS_BALANCE_WARN_FLOOR (per-denom) = 50_000n umfx | upwr\n *\n * Status semantics (CJS-parity):\n * - `'block'` — cannot proceed (SKU unavailable, wallet empty)\n * - `'warn'` — proceedable but risky (low credits, low gas balance, no credit account)\n * - `'ok'` — silent pass\n *\n * `suggestedActions` are semantic tokens from the frozen `ReadinessAction`\n * union — not prose for the user. Surfaces map these to UI affordances.\n *\n * Walked-from-CJS field-rename: the MCP response uses snake_case\n * (`wallet_balances`, `available_balances`, `hours_remaining`,\n * `current_balance`); the TS-port input is camelCase, and high-level\n * callers (PR 3's `deployApp`) translate the snake_case wire shape into\n * camelCase before passing in. ENG-258 Task 15 replaced the old flat\n * names field with `skuCandidates` / `availableSkuNames` derived from\n * `available_skus`.\n */\n\nconst HOURS_REMAINING_WARN_FLOOR = 24;\n\n// Per-denom warn floors for low gas balance (in smallest unit). Mirrors the\n// CJS values: 50_000 umfx = 0.05 MFX (1 MFX = 1,000,000 umfx); comparable\n// headroom for upwr.\nconst GAS_BALANCE_WARN_FLOOR_DEFAULTS: Readonly<Record<string, bigint>> = {\n umfx: 50_000n,\n upwr: 50_000n,\n};\nconst GAS_BALANCE_WARN_FLOOR_FALLBACK = 50_000n;\n\n/**\n * Cosmos convention for gas-price strings: leading numeric (digits +\n * optional decimal point), then the denom. Denom grammar mirrors\n * `sdk.ValidateDenom`: `[a-zA-Z][a-zA-Z0-9/:._-]{2,127}`.\n * Anchored both ends so trailing whitespace fails fast.\n */\nconst GAS_PRICE_RE = /^[0-9]+(?:\\.[0-9]+)?([a-zA-Z][a-zA-Z0-9/:._-]{2,127})$/;\n\n/**\n * Inputs passed to `evaluateReadiness`. camelCase throughout — high-level\n * callers translate the snake_case MCP response shape before invocation.\n */\nexport interface EvaluateReadinessInputs {\n /** Tenant address (bech32). Not consumed by the algorithm; included for journal/log context. */\n tenant: string;\n /** Image ref being considered (may be `null` when only the size is selected). */\n image: string | null;\n /** SKU size string the caller wants (`'docker-micro'`, etc.). `null` when not yet chosen. */\n size: string | null;\n /** Wallet bank balances. */\n walletBalances: Coin[];\n /** Credit account data, or `null` when no credit account is funded. */\n credits: {\n availableBalances?: Coin[];\n /** Older response variant — fallback when `availableBalances` is absent. */\n balances?: Coin[];\n /** Live current credit balance(s) when the tenant has at least one active lease. */\n currentBalance?: Coin[];\n /** Hours of runtime at the user's current overall burn rate (string-encoded number). */\n hoursRemaining?: string;\n } | null;\n /** Chosen SKU + price, or `null` when no size selected. */\n sku: { name: string; price: Coin } | null;\n /** All active SKU names the chain currently advertises. */\n availableSkuNames: string[];\n /**\n * Structured candidates for `size` (name + provider). When present, this is\n * preferred over `availableSkuNames` for the SKU-availability gate (ENG-258).\n */\n skuCandidates?: { name: string; providerUuid: string; price?: Coin }[];\n /**\n * Provider the caller pinned (if any). When `skuCandidates` is set, the\n * gate requires at least one candidate whose `providerUuid` matches (ENG-258).\n */\n requestedProviderUuid?: string;\n /** Gas-price string (e.g. `'1umfx'`, `'0.37upwr'`). Required — drives the wallet-gas check denom. */\n gasPrice: string;\n /** Override the per-denom warn floor (smallest unit). When omitted, uses the per-denom default or 50_000n fallback. */\n gasWarnFloor?: bigint;\n /**\n * Pre-loaded `DenomMap` for symbol humanization. The orchestrator\n * (`deploy-app.ts` and PR-4 callers) is responsible for composing the\n * map via `loadChainDenomMap(chainDataFile)` and passing it in. This\n * keeps `evaluateReadiness` pure-sync — I/O lives at the orchestrator\n * boundary, not inside the decision function (post-Q4 Bii verdict).\n *\n * When omitted, the no-op map is used; balances + SKU prices render\n * with raw on-chain denoms.\n */\n denomMap?: DenomMap;\n}\n\n/**\n * Compute the `Readiness` verdict for a prospective deployment.\n *\n * Throws `TypeError` on malformed `gasPrice` (the only input field whose\n * runtime shape isn't enforced by the typed signature).\n */\nexport function evaluateReadiness(inputs: EvaluateReadinessInputs): Readiness {\n // --- Parse + validate gasPrice via String.match (avoids RegExp.exec) ---\n const gasDenomMatch = inputs.gasPrice.match(GAS_PRICE_RE);\n if (!gasDenomMatch || gasDenomMatch[1] === undefined) {\n throw new TypeError(\n `evaluateReadiness: gasPrice must match <numeric><denom> (e.g. \"1umfx\" or \"0.37upwr\"); got \"${inputs.gasPrice}\"`,\n );\n }\n const gasDenom = gasDenomMatch[1];\n\n // --- Resolve gas warn floor ---\n const gasWarnFloor =\n inputs.gasWarnFloor !== undefined\n ? validateGasWarnFloor(inputs.gasWarnFloor)\n : (GAS_BALANCE_WARN_FLOOR_DEFAULTS[gasDenom] ??\n GAS_BALANCE_WARN_FLOOR_FALLBACK);\n\n // --- Resolve denom map ---\n // Pure-sync decision function: callers pre-load the map via\n // `loadChainDenomMap(chainDataFile)` and pass it in. When absent, the\n // empty no-op map is used (raw on-chain denom rendering downstream).\n // See Q4 Bii rationale in EvaluateReadinessInputs.denomMap docstring.\n const denomMap = inputs.denomMap ?? EMPTY_DENOM_MAP;\n\n // --- Walk readiness rules ---\n const reasons: string[] = [];\n const actions = new Set<ReadinessAction>();\n let status: Readiness['status'] = 'ok';\n\n // 1. SKU availability — block when the chosen size (+ provider) has no candidate.\n if (inputs.size !== null) {\n const candidates = inputs.skuCandidates;\n let available: boolean;\n if (candidates !== undefined) {\n // Candidate-based gate (ENG-258): prefer the structured list when present.\n available = candidates.some(\n (c) =>\n c.name === inputs.size &&\n (inputs.requestedProviderUuid === undefined ||\n c.providerUuid === inputs.requestedProviderUuid),\n );\n } else {\n // Legacy fallback: plain name list when no candidates supplied.\n available = inputs.availableSkuNames.includes(inputs.size);\n }\n if (!available) {\n status = 'block';\n if (candidates !== undefined) {\n // Candidate-based message (ENG-258): include the requested provider hint.\n const hint = inputs.requestedProviderUuid\n ? ` on provider ${inputs.requestedProviderUuid}`\n : '';\n reasons.push(\n `Requested SKU \"${inputs.size}\"${hint} is not currently offered.`,\n );\n } else {\n // Legacy message: include the available list for discoverability.\n const availableDisplay =\n inputs.availableSkuNames.length > 0\n ? inputs.availableSkuNames.join(', ')\n : '(none)';\n reasons.push(\n `Requested SKU \"${inputs.size}\" is not currently offered. Available: ${availableDisplay}.`,\n );\n }\n actions.add('pick_different_sku');\n }\n }\n\n // 2. Wallet gas balance — hard block on absent/zero, warn on below-floor.\n const gasEntry = inputs.walletBalances.find((b) => b.denom === gasDenom);\n const gasAmount = gasEntry ? asBigInt(gasEntry.amount) : 0n;\n if (inputs.walletBalances.length === 0 || gasAmount === 0n) {\n status = 'block';\n reasons.push(\n `Wallet has no ${denomToSymbol(gasDenom, denomMap)} balance for gas.`,\n );\n actions.add('request_faucet');\n actions.add('topup_wallet');\n } else if (gasAmount < gasWarnFloor) {\n if (status === 'ok') status = 'warn';\n reasons.push(\n `Wallet balance (${humanizeCoin(\n gasAmount.toString(),\n gasDenom,\n denomMap,\n )}) is below ${humanizeCoin(\n gasWarnFloor.toString(),\n gasDenom,\n denomMap,\n )}; broadcast may run out of gas.`,\n );\n actions.add('topup_wallet');\n }\n\n // 3. Credits.\n //\n // CJS preserves a subtle source-of-truth selection: `credits.availableBalances`\n // is the \"right now\" balance net of pending reservations; `credits.balances`\n // (older variant) is the gross-funded fallback; `currentBalance` is from\n // the chain's credit estimator and is only present when the tenant has at\n // least one ACTIVE lease. A fresh deployer with credits but no active\n // leases would have `currentBalance` ABSENT — reading that field FIRST as\n // the credit source produces a false \"Credit account is empty\" warning.\n // Mirror the CJS precedence: availableBalances → balances → currentBalance.\n const credits = inputs.credits;\n if (credits === null) {\n if (status === 'ok') status = 'warn';\n reasons.push('No credit account funded for compute leases.');\n actions.add('fund_credit');\n } else if (\n inputs.sku !== null &&\n inputs.sku.price.amount.length > 0 &&\n inputs.sku.price.denom.length > 0\n ) {\n const skuPrice = inputs.sku.price;\n const creditBalances: Coin[] = Array.isArray(credits.availableBalances)\n ? credits.availableBalances\n : Array.isArray(credits.balances)\n ? credits.balances\n : Array.isArray(credits.currentBalance)\n ? credits.currentBalance\n : [];\n const creditEntry = creditBalances.find((b) => b.denom === skuPrice.denom);\n const pricePerHour = asBigInt(skuPrice.amount);\n if (creditEntry === undefined) {\n // The credit account has NO entry in the SKU's price denom. Distinct\n // from \"credits ran out\" — usually means credits are funded in a\n // different denom than the SKU charges in. Emit a specific\n // diagnostic so the user knows to fund_credit in the right denom\n // rather than seeing a false \"0 hours of runtime\" warning.\n const fundedDenoms = creditBalances\n .map((b) => b.denom)\n .filter((d): d is string => typeof d === 'string' && d.length > 0);\n const skuSymbol = denomToSymbol(skuPrice.denom, denomMap);\n const fundedSymbols = fundedDenoms.map((d) => denomToSymbol(d, denomMap));\n if (status === 'ok') status = 'warn';\n reasons.push(\n fundedDenoms.length > 0\n ? `Credit account has no ${skuSymbol} balance (the ${inputs.sku.name} SKU charges in ${skuSymbol}; account holds ${fundedSymbols.join(\n ', ',\n )}). Fund ${skuSymbol} credits before deploying.`\n : `Credit account is empty for the ${inputs.sku.name} SKU's ${skuSymbol} denom. Fund ${skuSymbol} credits before deploying.`,\n );\n actions.add('fund_credit');\n } else if (pricePerHour > 0n) {\n const creditAmount = asBigInt(creditEntry.amount);\n // Convert via Number for the human-readable hours figure. Credit\n // amounts are in the chain's smallest unit and bounded well below\n // Number.MAX_SAFE_INTEGER for any realistic balance.\n const hrsForThisSku = Number(creditAmount) / Number(pricePerHour);\n if (hrsForThisSku < HOURS_REMAINING_WARN_FLOOR) {\n if (status === 'ok') status = 'warn';\n reasons.push(\n `Credits cover ~${hrsForThisSku.toFixed(1)}h of runtime at the ${inputs.sku.name} SKU (${humanizeCoin(\n creditAmount.toString(),\n skuPrice.denom,\n denomMap,\n )} / ${humanizeCoin(\n pricePerHour.toString(),\n skuPrice.denom,\n denomMap,\n )} per hour); below the ${HOURS_REMAINING_WARN_FLOOR}h floor.`,\n );\n actions.add('fund_credit');\n }\n }\n } else if (credits.hoursRemaining !== undefined) {\n // Fallback for cases where SKU pricing is not available (e.g. caller\n // didn't pass --size). Use the chain's hoursRemaining but ONLY warn\n // when it's a meaningful positive number below the floor — `0` here\n // means \"no current burn\", not \"low credits\".\n const hrs = Number(credits.hoursRemaining);\n if (Number.isFinite(hrs) && hrs > 0 && hrs < HOURS_REMAINING_WARN_FLOOR) {\n if (status === 'ok') status = 'warn';\n reasons.push(\n `Credits cover ~${hrs.toFixed(1)}h of runtime at the current burn rate; below the ${HOURS_REMAINING_WARN_FLOOR}h floor.`,\n );\n actions.add('fund_credit');\n }\n }\n\n // --- Map input shape into the frozen `Readiness` carrier fields ---\n const creditsOut: Readiness['credits'] =\n credits === null\n ? null\n : {\n availableBalances: Array.isArray(credits.availableBalances)\n ? credits.availableBalances\n : Array.isArray(credits.balances)\n ? credits.balances\n : Array.isArray(credits.currentBalance)\n ? credits.currentBalance\n : [],\n };\n\n return {\n status,\n reasons,\n suggestedActions: Array.from(actions),\n walletBalances: inputs.walletBalances,\n credits: creditsOut,\n sku: inputs.sku,\n };\n}\n\nfunction asBigInt(s: string): bigint {\n try {\n return BigInt(s);\n } catch {\n return 0n;\n }\n}\n\nfunction validateGasWarnFloor(value: bigint): bigint {\n if (value < 0n) {\n throw new TypeError(\n `evaluateReadiness: gasWarnFloor must be a non-negative integer, got ${value}`,\n );\n }\n return value;\n}\n"],"mappings":"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"}
@@ -15,6 +15,12 @@ interface RenderDeploymentPlanInput {
15
15
  customDomain?: string;
16
16
  /** Optional stack-service holding the custom domain. */
17
17
  customDomainService?: string;
18
+ /**
19
+ * Pinned provider UUID resolved by the SKU disambiguator (ENG-258).
20
+ * When non-empty, a `Provider:` line is rendered right after `Size:`
21
+ * so the user sees which provider they are deploying to.
22
+ */
23
+ providerUuid?: string;
18
24
  }
19
25
  declare function renderDeploymentPlan(input: RenderDeploymentPlanInput): DeploymentPlanBlock;
20
26
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"render-deployment-plan.d.ts","names":[],"sources":["../../src/internals/render-deployment-plan.ts"],"mappings":";;UAgHiB,yBAAA;;EAEf,IAAA,EAAM,IAAA;EAFkC;EAIxC,QAAA,GAAW,QAAQ;EAAA;EAEnB,KAAA;EAJM;EAMN,IAAA;EAJW;EAMX,QAAA;EAFA;EAIA,YAAA;EAAA;EAEA,mBAAA;AAAA;AAAA,iBAGc,oBAAA,CACd,KAAA,EAAO,yBAAA,GACN,mBAAmB"}
1
+ {"version":3,"file":"render-deployment-plan.d.ts","names":[],"sources":["../../src/internals/render-deployment-plan.ts"],"mappings":";;UAkHiB,yBAAA;;EAEf,IAAA,EAAM,IAAA;EAFkC;EAIxC,QAAA,GAAW,QAAQ;EAAA;EAEnB,KAAA;EAJM;EAMN,IAAA;EAJW;EAMX,QAAA;EAFA;EAIA,YAAA;EAAA;EAEA,mBAAA;EAMA;;AAAY;AAGd;;EAHE,YAAA;AAAA;AAAA,iBAGc,oBAAA,CACd,KAAA,EAAO,yBAAA,GACN,mBAAmB"}
@@ -31,8 +31,10 @@ import { EMPTY_DENOM_MAP, humanizeBalances, humanizeCoin } from "./humanize-deno
31
31
  * representative lease...)" message preserving the CJS's user-facing
32
32
  * "skipped" semantics.
33
33
  *
34
- * Provider line is intentionally absent (chain selects internally; format-
35
- * success.ts emits it post-deploy).
34
+ * **`Provider:` line (ENG-258):** rendered immediately after `Size:` when a
35
+ * pinned `providerUuid` is supplied (resolved by the SKU disambiguator so the
36
+ * user sees which provider they are deploying to). Omitted when the field is
37
+ * absent or empty (single-provider SKU — chain selects internally).
36
38
  */
37
39
  /**
38
40
  * Same-denom single-coin: sum as `BigInt` (the underlying on-chain
@@ -98,13 +100,14 @@ function renderDeploymentPlan(input) {
98
100
  const hasDomain = typeof input.customDomain === "string" && input.customDomain.length > 0;
99
101
  const createFee = input.plan.fees.createLease;
100
102
  const createFeeLine = formatFeeLine(humanizeFeeAmount(createFee, denomMap), createFee.gas);
103
+ const hasProvider = typeof input.providerUuid === "string" && input.providerUuid.length > 0;
101
104
  const lines = [
102
105
  "DeploymentPlan",
103
106
  ` Image: ${input.image}`,
104
- ` Size: ${input.size}`,
105
- ` Manifest: ${manifestLine}`,
106
- ` meta_hash: ${input.metaHash}`
107
+ ` Size: ${input.size}`
107
108
  ];
109
+ if (hasProvider) lines.push(` Provider: ${input.providerUuid}`);
110
+ lines.push(` Manifest: ${manifestLine}`, ` meta_hash: ${input.metaHash}`);
108
111
  if (hasDomain) {
109
112
  const target = typeof input.customDomainService === "string" && input.customDomainService.length > 0 ? `-> service ${input.customDomainService}` : "-> single-service lease";
110
113
  lines.push(` Custom domain: ${input.customDomain} ${target}`);
@@ -1 +1 @@
1
- {"version":3,"file":"render-deployment-plan.js","names":[],"sources":["../../src/internals/render-deployment-plan.ts"],"sourcesContent":["import type { DeploymentPlanBlock, FeeEstimate, Plan } from '../types.js';\nimport {\n type DenomMap,\n EMPTY_DENOM_MAP,\n humanizeBalances,\n humanizeCoin,\n} from './humanize-denom.js';\n\n/**\n * Render the canonical `DeploymentPlan` block for `deployApp`'s\n * confirmation step. Consumes the typed `Plan` + `FeeEstimate {coins, gas}`\n * shape.\n *\n * **Why this is a renderer, not a builder:** the function consumes a\n * fully-resolved `Plan` (summary + readiness + fees) plus orchestrator-\n * supplied trim data (image / size / metaHash / customDomain). It\n * doesn't compose those inputs — `deployApp.ts` (commit B) constructs\n * the `Plan` from chain queries + estimates and threads it here.\n *\n * **Sync, pure-decision function** (per Q4 Bii pattern): no I/O, no\n * mutation, no implicit lookups. Caller pre-loads the `DenomMap` via\n * `await loadChainDenomMap(chainDataFile)` and passes it in. Default\n * fallback is the no-op `EMPTY_DENOM_MAP` — raw on-chain denoms render\n * verbatim. The `(empty)` literal continues to mark missing balances.\n *\n * **Fee humanization:** the new `FeeEstimate {coins: Coin[], gas}` shape\n * preserves multi-coin precision. The CJS read pre-humanized strings\n * (`--tx-fee \"0.0023 MFX\"`); the TS port humanizes `fees.coins[0]` at\n * render time using `humanizeCoin`, then concatenates with `(gas <n>)`.\n * Multi-coin fees: humanizes all coins with `humanizeBalances` (comma-\n * separated) and renders the result verbatim — gas suffix is appended\n * once.\n *\n * **`setDomain` fee sentinel:** when `plan.fees.setDomain` is the\n * `{notEstimated: true, reason}` sentinel (approach-3 no-representative-\n * lease fallback), the line emits the explicit \"(not estimated — no\n * representative lease...)\" message preserving the CJS's user-facing\n * \"skipped\" semantics.\n *\n * Provider line is intentionally absent (chain selects internally; format-\n * success.ts emits it post-deploy).\n */\n\n/**\n * Same-denom single-coin: sum as `BigInt` (the underlying on-chain\n * unit), then humanize the total. Different denom OR multi-coin:\n * `\"<a> + <b>\"` concat (mirrors the CJS's `sumHumanFees` fallback).\n *\n * Copilot review fix (PR #58 r3250445951): the prior `sumHumanFees`\n * parsed humanized strings to float64, summed, and re-formatted —\n * breaking the BigInt invariant the rest of the denom-humanization\n * pipeline maintains (`humanize-denom.ts:_fmtScaledAmount` is\n * BigInt-based). Realistic create-lease + set-domain fees were tiny\n * so the hit rate was low; the inconsistency was real, and amounts\n * above `Number.MAX_SAFE_INTEGER` (2^53-1) would silently round.\n *\n * Operates on the underlying `FeeEstimate.coins` arrays directly so\n * BigInt precision is preserved through the sum. Humanization\n * happens once, at the end.\n */\nfunction sumFees(a: FeeEstimate, b: FeeEstimate, denomMap: DenomMap): string {\n // Same-denom single-coin: BigInt sum, then humanize.\n if (a.coins.length === 1 && b.coins.length === 1) {\n const ca = a.coins[0];\n const cb = b.coins[0];\n if (ca && cb && ca.denom === cb.denom) {\n const sum = (BigInt(ca.amount) + BigInt(cb.amount)).toString();\n return humanizeCoin(sum, ca.denom, denomMap);\n }\n }\n // Different denom or multi-coin: fall back to concat, mirroring the\n // CJS's behavior. Humanize each side independently.\n return `${humanizeFeeAmount(a, denomMap)} + ${humanizeFeeAmount(b, denomMap)}`;\n}\n\n/**\n * Render a `FeeEstimate {coins, gas}` as the user-facing fee string.\n * Empty coins → `(empty)` literal (CJS parity). Single coin → humanized\n * `\"<amount> <symbol>\"`. Multi-coin → comma-joined.\n */\nfunction humanizeFeeAmount(fee: FeeEstimate, denomMap: DenomMap): string {\n if (fee.coins.length === 0) return '(empty)';\n if (fee.coins.length === 1) {\n const c = fee.coins[0];\n if (c === undefined) return '(empty)';\n return humanizeCoin(c.amount, c.denom, denomMap);\n }\n return humanizeBalances(fee.coins, denomMap);\n}\n\nfunction formatFeeLine(humanFee: string, gas: number): string {\n return `${humanFee} (gas ${gas})`;\n}\n\nfunction formatSkuPrice(plan: Plan, denomMap: DenomMap): string {\n const sku = plan.readiness.sku;\n if (sku === null) return '(unknown — SKU has no listed price)';\n return `${humanizeCoin(sku.price.amount, sku.price.denom, denomMap)} / hour`;\n}\n\nfunction formatWallet(plan: Plan, denomMap: DenomMap): string {\n return humanizeBalances(plan.readiness.walletBalances, denomMap);\n}\n\nfunction formatCredits(plan: Plan, denomMap: DenomMap): string {\n const credits = plan.readiness.credits;\n if (credits === null) return 'none';\n const balances = credits.availableBalances;\n if (!Array.isArray(balances) || balances.length === 0) return '(empty)';\n return humanizeBalances(balances, denomMap);\n}\n\nexport interface RenderDeploymentPlanInput {\n /** Frozen Plan (summary + readiness + fees). */\n plan: Plan;\n /** Pre-loaded denom map. Default: `EMPTY_DENOM_MAP` (raw on-chain rendering). */\n denomMap?: DenomMap;\n /** Primary image reference — first service's image for stacks. */\n image: string;\n /** SKU tier name (e.g. `docker-micro`, `small`). */\n size: string;\n /** Manifest meta-hash hex from `build_manifest_preview`. */\n metaHash: string;\n /** Optional custom-domain FQDN; presence drives the two-tx fee layout. */\n customDomain?: string;\n /** Optional stack-service holding the custom domain. */\n customDomainService?: string;\n}\n\nexport function renderDeploymentPlan(\n input: RenderDeploymentPlanInput,\n): DeploymentPlanBlock {\n const denomMap = input.denomMap ?? EMPTY_DENOM_MAP;\n const { summary } = input.plan;\n\n const manifestLine =\n `${summary.format ?? 'single'}, services=${summary.serviceCount}, ` +\n `ports=${summary.portCount}, env=${summary.envCount}`;\n\n const hasDomain =\n typeof input.customDomain === 'string' && input.customDomain.length > 0;\n\n // Create-lease fee — always present in PlanFees.\n const createFee = input.plan.fees.createLease;\n const createHuman = humanizeFeeAmount(createFee, denomMap);\n const createFeeLine = formatFeeLine(createHuman, createFee.gas);\n\n const lines: string[] = [\n 'DeploymentPlan',\n ` Image: ${input.image}`,\n ` Size: ${input.size}`,\n ` Manifest: ${manifestLine}`,\n ` meta_hash: ${input.metaHash}`,\n ];\n\n if (hasDomain) {\n const target =\n typeof input.customDomainService === 'string' &&\n input.customDomainService.length > 0\n ? `-> service ${input.customDomainService}`\n : '-> single-service lease';\n lines.push(` Custom domain: ${input.customDomain} ${target}`);\n }\n\n lines.push(\n ` SKU price: ${formatSkuPrice(input.plan, denomMap)}`,\n );\n\n if (hasDomain) {\n // Two-tx layout: labeled lines + Total fee. Honors approach-3\n // `notEstimated` sentinel for set-domain pre-broadcast estimation\n // fallback (no representative lease).\n const setDomain = input.plan.fees.setDomain;\n let setDomainLine: string;\n // Capture the typed `FeeEstimate` reference (when the set-domain\n // fee is a real estimate, not the sentinel) so the total-line\n // BigInt sum can operate on `coins` directly via `sumFees`. The\n // prior code parsed humanized strings to float64 — see\n // `sumFees`'s docstring for the precision-loss rationale.\n let setDomainReal: FeeEstimate | null = null;\n if (setDomain === undefined) {\n setDomainLine =\n '(not estimated — agent skipped pre-broadcast simulation, policy violation)';\n } else if ('notEstimated' in setDomain) {\n setDomainLine = `(not estimated — ${setDomain.reason})`;\n } else {\n setDomainReal = setDomain;\n setDomainLine = formatFeeLine(\n humanizeFeeAmount(setDomain, denomMap),\n setDomain.gas,\n );\n }\n\n lines.push(` Tx fee (create-lease): ${createFeeLine}`);\n lines.push(` Tx fee (set-domain): ${setDomainLine}`);\n\n // Total only when both fees are real numbers. Sentinel set-domain\n // fees fall through to the placeholder.\n const totalLine =\n setDomainReal !== null\n ? sumFees(createFee, setDomainReal, denomMap)\n : '(partial — see fee lines above)';\n lines.push(` Total fee: ${totalLine}`);\n } else {\n lines.push(` Tx fee: ${createFeeLine}`);\n }\n\n lines.push(\n ` Wallet: ${formatWallet(input.plan, denomMap)}`,\n );\n lines.push(\n ` Credits: ${formatCredits(input.plan, denomMap)}`,\n );\n\n return { text: lines.join('\\n') };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4DA,SAAS,QAAQ,GAAgB,GAAgB,UAA4B;CAE3E,IAAI,EAAE,MAAM,WAAW,KAAK,EAAE,MAAM,WAAW,GAAG;EAChD,MAAM,KAAK,EAAE,MAAM;EACnB,MAAM,KAAK,EAAE,MAAM;EACnB,IAAI,MAAM,MAAM,GAAG,UAAU,GAAG,OAE9B,OAAO,cADM,OAAO,GAAG,MAAM,IAAI,OAAO,GAAG,MAAM,GAAG,SAC9B,GAAG,GAAG,OAAO,QAAQ;CAE/C;CAGA,OAAO,GAAG,kBAAkB,GAAG,QAAQ,EAAE,KAAK,kBAAkB,GAAG,QAAQ;AAC7E;;;;;;AAOA,SAAS,kBAAkB,KAAkB,UAA4B;CACvE,IAAI,IAAI,MAAM,WAAW,GAAG,OAAO;CACnC,IAAI,IAAI,MAAM,WAAW,GAAG;EAC1B,MAAM,IAAI,IAAI,MAAM;EACpB,IAAI,MAAM,KAAA,GAAW,OAAO;EAC5B,OAAO,aAAa,EAAE,QAAQ,EAAE,OAAO,QAAQ;CACjD;CACA,OAAO,iBAAiB,IAAI,OAAO,QAAQ;AAC7C;AAEA,SAAS,cAAc,UAAkB,KAAqB;CAC5D,OAAO,GAAG,SAAS,QAAQ,IAAI;AACjC;AAEA,SAAS,eAAe,MAAY,UAA4B;CAC9D,MAAM,MAAM,KAAK,UAAU;CAC3B,IAAI,QAAQ,MAAM,OAAO;CACzB,OAAO,GAAG,aAAa,IAAI,MAAM,QAAQ,IAAI,MAAM,OAAO,QAAQ,EAAE;AACtE;AAEA,SAAS,aAAa,MAAY,UAA4B;CAC5D,OAAO,iBAAiB,KAAK,UAAU,gBAAgB,QAAQ;AACjE;AAEA,SAAS,cAAc,MAAY,UAA4B;CAC7D,MAAM,UAAU,KAAK,UAAU;CAC/B,IAAI,YAAY,MAAM,OAAO;CAC7B,MAAM,WAAW,QAAQ;CACzB,IAAI,CAAC,MAAM,QAAQ,QAAQ,KAAK,SAAS,WAAW,GAAG,OAAO;CAC9D,OAAO,iBAAiB,UAAU,QAAQ;AAC5C;AAmBA,SAAgB,qBACd,OACqB;CACrB,MAAM,WAAW,MAAM,YAAY;CACnC,MAAM,EAAE,YAAY,MAAM;CAE1B,MAAM,eACJ,GAAG,QAAQ,UAAU,SAAS,aAAa,QAAQ,aAAa,UACvD,QAAQ,UAAU,QAAQ,QAAQ;CAE7C,MAAM,YACJ,OAAO,MAAM,iBAAiB,YAAY,MAAM,aAAa,SAAS;CAGxE,MAAM,YAAY,MAAM,KAAK,KAAK;CAElC,MAAM,gBAAgB,cADF,kBAAkB,WAAW,QACH,GAAG,UAAU,GAAG;CAE9D,MAAM,QAAkB;EACtB;EACA,gCAAgC,MAAM;EACtC,gCAAgC,MAAM;EACtC,gCAAgC;EAChC,gCAAgC,MAAM;CACxC;CAEA,IAAI,WAAW;EACb,MAAM,SACJ,OAAO,MAAM,wBAAwB,YACrC,MAAM,oBAAoB,SAAS,IAC/B,cAAc,MAAM,wBACpB;EACN,MAAM,KAAK,gCAAgC,MAAM,aAAa,GAAG,QAAQ;CAC3E;CAEA,MAAM,KACJ,gCAAgC,eAAe,MAAM,MAAM,QAAQ,GACrE;CAEA,IAAI,WAAW;EAIb,MAAM,YAAY,MAAM,KAAK,KAAK;EAClC,IAAI;EAMJ,IAAI,gBAAoC;EACxC,IAAI,cAAc,KAAA,GAChB,gBACE;OACG,IAAI,kBAAkB,WAC3B,gBAAgB,oBAAoB,UAAU,OAAO;OAChD;GACL,gBAAgB;GAChB,gBAAgB,cACd,kBAAkB,WAAW,QAAQ,GACrC,UAAU,GACZ;EACF;EAEA,MAAM,KAAK,gCAAgC,eAAe;EAC1D,MAAM,KAAK,gCAAgC,eAAe;EAI1D,MAAM,YACJ,kBAAkB,OACd,QAAQ,WAAW,eAAe,QAAQ,IAC1C;EACN,MAAM,KAAK,gCAAgC,WAAW;CACxD,OACE,MAAM,KAAK,gCAAgC,eAAe;CAG5D,MAAM,KACJ,gCAAgC,aAAa,MAAM,MAAM,QAAQ,GACnE;CACA,MAAM,KACJ,gCAAgC,cAAc,MAAM,MAAM,QAAQ,GACpE;CAEA,OAAO,EAAE,MAAM,MAAM,KAAK,IAAI,EAAE;AAClC"}
1
+ {"version":3,"file":"render-deployment-plan.js","names":[],"sources":["../../src/internals/render-deployment-plan.ts"],"sourcesContent":["import type { DeploymentPlanBlock, FeeEstimate, Plan } from '../types.js';\nimport {\n type DenomMap,\n EMPTY_DENOM_MAP,\n humanizeBalances,\n humanizeCoin,\n} from './humanize-denom.js';\n\n/**\n * Render the canonical `DeploymentPlan` block for `deployApp`'s\n * confirmation step. Consumes the typed `Plan` + `FeeEstimate {coins, gas}`\n * shape.\n *\n * **Why this is a renderer, not a builder:** the function consumes a\n * fully-resolved `Plan` (summary + readiness + fees) plus orchestrator-\n * supplied trim data (image / size / metaHash / customDomain). It\n * doesn't compose those inputs — `deployApp.ts` (commit B) constructs\n * the `Plan` from chain queries + estimates and threads it here.\n *\n * **Sync, pure-decision function** (per Q4 Bii pattern): no I/O, no\n * mutation, no implicit lookups. Caller pre-loads the `DenomMap` via\n * `await loadChainDenomMap(chainDataFile)` and passes it in. Default\n * fallback is the no-op `EMPTY_DENOM_MAP` — raw on-chain denoms render\n * verbatim. The `(empty)` literal continues to mark missing balances.\n *\n * **Fee humanization:** the new `FeeEstimate {coins: Coin[], gas}` shape\n * preserves multi-coin precision. The CJS read pre-humanized strings\n * (`--tx-fee \"0.0023 MFX\"`); the TS port humanizes `fees.coins[0]` at\n * render time using `humanizeCoin`, then concatenates with `(gas <n>)`.\n * Multi-coin fees: humanizes all coins with `humanizeBalances` (comma-\n * separated) and renders the result verbatim — gas suffix is appended\n * once.\n *\n * **`setDomain` fee sentinel:** when `plan.fees.setDomain` is the\n * `{notEstimated: true, reason}` sentinel (approach-3 no-representative-\n * lease fallback), the line emits the explicit \"(not estimated — no\n * representative lease...)\" message preserving the CJS's user-facing\n * \"skipped\" semantics.\n *\n * **`Provider:` line (ENG-258):** rendered immediately after `Size:` when a\n * pinned `providerUuid` is supplied (resolved by the SKU disambiguator so the\n * user sees which provider they are deploying to). Omitted when the field is\n * absent or empty (single-provider SKU — chain selects internally).\n */\n\n/**\n * Same-denom single-coin: sum as `BigInt` (the underlying on-chain\n * unit), then humanize the total. Different denom OR multi-coin:\n * `\"<a> + <b>\"` concat (mirrors the CJS's `sumHumanFees` fallback).\n *\n * Copilot review fix (PR #58 r3250445951): the prior `sumHumanFees`\n * parsed humanized strings to float64, summed, and re-formatted —\n * breaking the BigInt invariant the rest of the denom-humanization\n * pipeline maintains (`humanize-denom.ts:_fmtScaledAmount` is\n * BigInt-based). Realistic create-lease + set-domain fees were tiny\n * so the hit rate was low; the inconsistency was real, and amounts\n * above `Number.MAX_SAFE_INTEGER` (2^53-1) would silently round.\n *\n * Operates on the underlying `FeeEstimate.coins` arrays directly so\n * BigInt precision is preserved through the sum. Humanization\n * happens once, at the end.\n */\nfunction sumFees(a: FeeEstimate, b: FeeEstimate, denomMap: DenomMap): string {\n // Same-denom single-coin: BigInt sum, then humanize.\n if (a.coins.length === 1 && b.coins.length === 1) {\n const ca = a.coins[0];\n const cb = b.coins[0];\n if (ca && cb && ca.denom === cb.denom) {\n const sum = (BigInt(ca.amount) + BigInt(cb.amount)).toString();\n return humanizeCoin(sum, ca.denom, denomMap);\n }\n }\n // Different denom or multi-coin: fall back to concat, mirroring the\n // CJS's behavior. Humanize each side independently.\n return `${humanizeFeeAmount(a, denomMap)} + ${humanizeFeeAmount(b, denomMap)}`;\n}\n\n/**\n * Render a `FeeEstimate {coins, gas}` as the user-facing fee string.\n * Empty coins → `(empty)` literal (CJS parity). Single coin → humanized\n * `\"<amount> <symbol>\"`. Multi-coin → comma-joined.\n */\nfunction humanizeFeeAmount(fee: FeeEstimate, denomMap: DenomMap): string {\n if (fee.coins.length === 0) return '(empty)';\n if (fee.coins.length === 1) {\n const c = fee.coins[0];\n if (c === undefined) return '(empty)';\n return humanizeCoin(c.amount, c.denom, denomMap);\n }\n return humanizeBalances(fee.coins, denomMap);\n}\n\nfunction formatFeeLine(humanFee: string, gas: number): string {\n return `${humanFee} (gas ${gas})`;\n}\n\nfunction formatSkuPrice(plan: Plan, denomMap: DenomMap): string {\n const sku = plan.readiness.sku;\n if (sku === null) return '(unknown — SKU has no listed price)';\n return `${humanizeCoin(sku.price.amount, sku.price.denom, denomMap)} / hour`;\n}\n\nfunction formatWallet(plan: Plan, denomMap: DenomMap): string {\n return humanizeBalances(plan.readiness.walletBalances, denomMap);\n}\n\nfunction formatCredits(plan: Plan, denomMap: DenomMap): string {\n const credits = plan.readiness.credits;\n if (credits === null) return 'none';\n const balances = credits.availableBalances;\n if (!Array.isArray(balances) || balances.length === 0) return '(empty)';\n return humanizeBalances(balances, denomMap);\n}\n\nexport interface RenderDeploymentPlanInput {\n /** Frozen Plan (summary + readiness + fees). */\n plan: Plan;\n /** Pre-loaded denom map. Default: `EMPTY_DENOM_MAP` (raw on-chain rendering). */\n denomMap?: DenomMap;\n /** Primary image reference — first service's image for stacks. */\n image: string;\n /** SKU tier name (e.g. `docker-micro`, `small`). */\n size: string;\n /** Manifest meta-hash hex from `build_manifest_preview`. */\n metaHash: string;\n /** Optional custom-domain FQDN; presence drives the two-tx fee layout. */\n customDomain?: string;\n /** Optional stack-service holding the custom domain. */\n customDomainService?: string;\n /**\n * Pinned provider UUID resolved by the SKU disambiguator (ENG-258).\n * When non-empty, a `Provider:` line is rendered right after `Size:`\n * so the user sees which provider they are deploying to.\n */\n providerUuid?: string;\n}\n\nexport function renderDeploymentPlan(\n input: RenderDeploymentPlanInput,\n): DeploymentPlanBlock {\n const denomMap = input.denomMap ?? EMPTY_DENOM_MAP;\n const { summary } = input.plan;\n\n const manifestLine =\n `${summary.format ?? 'single'}, services=${summary.serviceCount}, ` +\n `ports=${summary.portCount}, env=${summary.envCount}`;\n\n const hasDomain =\n typeof input.customDomain === 'string' && input.customDomain.length > 0;\n\n // Create-lease fee — always present in PlanFees.\n const createFee = input.plan.fees.createLease;\n const createHuman = humanizeFeeAmount(createFee, denomMap);\n const createFeeLine = formatFeeLine(createHuman, createFee.gas);\n\n const hasProvider =\n typeof input.providerUuid === 'string' && input.providerUuid.length > 0;\n\n const lines: string[] = [\n 'DeploymentPlan',\n ` Image: ${input.image}`,\n ` Size: ${input.size}`,\n ];\n\n if (hasProvider) {\n lines.push(` Provider: ${input.providerUuid}`);\n }\n\n lines.push(\n ` Manifest: ${manifestLine}`,\n ` meta_hash: ${input.metaHash}`,\n );\n\n if (hasDomain) {\n const target =\n typeof input.customDomainService === 'string' &&\n input.customDomainService.length > 0\n ? `-> service ${input.customDomainService}`\n : '-> single-service lease';\n lines.push(` Custom domain: ${input.customDomain} ${target}`);\n }\n\n lines.push(\n ` SKU price: ${formatSkuPrice(input.plan, denomMap)}`,\n );\n\n if (hasDomain) {\n // Two-tx layout: labeled lines + Total fee. Honors approach-3\n // `notEstimated` sentinel for set-domain pre-broadcast estimation\n // fallback (no representative lease).\n const setDomain = input.plan.fees.setDomain;\n let setDomainLine: string;\n // Capture the typed `FeeEstimate` reference (when the set-domain\n // fee is a real estimate, not the sentinel) so the total-line\n // BigInt sum can operate on `coins` directly via `sumFees`. The\n // prior code parsed humanized strings to float64 — see\n // `sumFees`'s docstring for the precision-loss rationale.\n let setDomainReal: FeeEstimate | null = null;\n if (setDomain === undefined) {\n setDomainLine =\n '(not estimated — agent skipped pre-broadcast simulation, policy violation)';\n } else if ('notEstimated' in setDomain) {\n setDomainLine = `(not estimated — ${setDomain.reason})`;\n } else {\n setDomainReal = setDomain;\n setDomainLine = formatFeeLine(\n humanizeFeeAmount(setDomain, denomMap),\n setDomain.gas,\n );\n }\n\n lines.push(` Tx fee (create-lease): ${createFeeLine}`);\n lines.push(` Tx fee (set-domain): ${setDomainLine}`);\n\n // Total only when both fees are real numbers. Sentinel set-domain\n // fees fall through to the placeholder.\n const totalLine =\n setDomainReal !== null\n ? sumFees(createFee, setDomainReal, denomMap)\n : '(partial — see fee lines above)';\n lines.push(` Total fee: ${totalLine}`);\n } else {\n lines.push(` Tx fee: ${createFeeLine}`);\n }\n\n lines.push(\n ` Wallet: ${formatWallet(input.plan, denomMap)}`,\n );\n lines.push(\n ` Credits: ${formatCredits(input.plan, denomMap)}`,\n );\n\n return { text: lines.join('\\n') };\n}\n"],"mappings":"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"}
@@ -6,9 +6,10 @@ import { DeploySpec, ServiceDef, SingleServiceSpec, SpecSummary, StackSpec } fro
6
6
  * `firstImage`, `normalizeServices`, `summarizeSpec`, and `validateSpec`
7
7
  * (the latter surfaces pre-broadcast shape violations).
8
8
  *
9
- * Two spec shapes are supported (frozen in ENG-128's `types.ts`):
10
- * - **services-map (StackSpec)** — `{ services: { <name>: ServiceDef }, customDomain?, serviceName? }`
11
- * - **legacy single-service (SingleServiceSpec)** — `{ image, port?, env?, customDomain? }`
9
+ * Two spec shapes are supported (defined in `types.ts`; `size?` added in
10
+ * ENG-275):
11
+ * - **services-map (StackSpec)** — `{ services: { <name>: ServiceDef }, customDomain?, serviceName?, size? }`
12
+ * - **legacy single-service (SingleServiceSpec)** — `{ image, port?, env?, customDomain?, size? }`
12
13
  *
13
14
  * `normalizeServices` collapses the two shapes into a single iterable form
14
15
  * so callers (Plan summary, manifest builder, etc.) walk one structure
@@ -1 +1 @@
1
- {"version":3,"file":"spec-normalize.d.ts","names":[],"sources":["../../src/internals/spec-normalize.ts"],"mappings":";;;;;AAgCA;;;;;;;;;AAEoB;AAiBpB;;;;AAA8D;AAyB9D;;;;;;iBA5CgB,WAAA,CACd,IAAA,EAAM,UAAA,sBACL,IAAA,IAAQ,SAAS;;;AA8CiB;AAGrC;;;iBAhCgB,UAAA,CAAW,IAAmC,EAA7B,UAAU;;;;;AAkCvB;AA6BpB;;UAtCiB,iBAAA;EAsC2C;EApC1D,IAAA;EAoC4B;EAlC5B,GAAA,EAAK,UAAA,GAAa,iBAAiB;AAAA;AAAA,iBAGrB,iBAAA,CACd,IAAA,EAAM,UAAA,sBACL,iBAAiB;AAwFpB;;;;AAAgE;;;;;;;;;;AAAhE,iBA3DgB,aAAA,CAAc,IAAA,EAAM,UAAA,GAAa,WAAW;;;;;;;;;;;;;;;;;;iBA2D5C,YAAA,CAAa,IAAmC,EAA7B,UAAU"}
1
+ {"version":3,"file":"spec-normalize.d.ts","names":[],"sources":["../../src/internals/spec-normalize.ts"],"mappings":";;;;;AAiCA;;;;;;;;;AAEoB;AAiBpB;;;;AAA8D;AAyB9D;;;;;;;iBA5CgB,WAAA,CACd,IAAA,EAAM,UAAA,sBACL,IAAA,IAAQ,SAAS;;AA8CiB;AAGrC;;;;iBAhCgB,UAAA,CAAW,IAAmC,EAA7B,UAAU;;;;AAkCvB;AA6BpB;;;UAtCiB,iBAAA;EAsCmB;EApClC,IAAA;EAoC+C;EAlC/C,GAAA,EAAK,UAAA,GAAa,iBAAiB;AAAA;AAAA,iBAGrB,iBAAA,CACd,IAAA,EAAM,UAAA,sBACL,iBAAiB;;;;AAwF4C;;;;;;;;;;;iBA3DhD,aAAA,CAAc,IAAA,EAAM,UAAA,GAAa,WAAW;;;;;;;;;;;;;;;;;;iBA2D5C,YAAA,CAAa,IAAmC,EAA7B,UAAU"}
@@ -4,9 +4,10 @@
4
4
  * `firstImage`, `normalizeServices`, `summarizeSpec`, and `validateSpec`
5
5
  * (the latter surfaces pre-broadcast shape violations).
6
6
  *
7
- * Two spec shapes are supported (frozen in ENG-128's `types.ts`):
8
- * - **services-map (StackSpec)** — `{ services: { <name>: ServiceDef }, customDomain?, serviceName? }`
9
- * - **legacy single-service (SingleServiceSpec)** — `{ image, port?, env?, customDomain? }`
7
+ * Two spec shapes are supported (defined in `types.ts`; `size?` added in
8
+ * ENG-275):
9
+ * - **services-map (StackSpec)** — `{ services: { <name>: ServiceDef }, customDomain?, serviceName?, size? }`
10
+ * - **legacy single-service (SingleServiceSpec)** — `{ image, port?, env?, customDomain?, size? }`
10
11
  *
11
12
  * `normalizeServices` collapses the two shapes into a single iterable form
12
13
  * so callers (Plan summary, manifest builder, etc.) walk one structure